detached 7091acb

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 UnverifiedB5690EEEBB952194 The key is unknown
170 files · +15319 -9534
M AGENTS.md +2 -2
@@ -12,7 +12,7 @@ make test
1212 go test ./internal/usecase/daemon -race -run TestName
1313 make lint
1414 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
1616 ```
1717
1818 - Format with **goimports**.
@@ -68,7 +68,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6868 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`.
6969
7070 - 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.
7272 - Version negotiation requires strict equality.
7373 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7474 - 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
4242
4343 `--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.
4444
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+
4567 ## Session composition
4668
4769 ```text
@@ -61,4 +83,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
6183 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
6284 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
6385 - 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.
4545 cause while the originating interaction is still open; stale failures
4646 from closed interactions drop. Native commands stay usable throughout.
4747
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+
4856 ## Remote vision
4957
5058 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) {
182182 t.Fatalf("observes = %d, want 4", observes)
183183 }
184184
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"}}}
191191 jobs <- second
192192 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
196203 }
197204
198205 close(cache.release)
@@ -200,15 +207,15 @@ func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) {
200207 if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil {
201208 t.Fatalf("store result = %+v", firstStored)
202209 }
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)
206213 }
207214 cache.mu.Lock()
208215 defer cache.mu.Unlock()
209216 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)
212219 }
213220 }
214221
M internal/adapters/sessionwire/client.go +67 -16
@@ -91,8 +91,6 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
9191 return wire.UnmarshalSessions(frame.Payload)
9292 case wire.MsgCommandResult:
9393 return wire.UnmarshalCommandResult(frame.Payload)
94- case wire.MsgNavigationAction:
95- return wire.UnmarshalNavigationDirective(frame.Payload)
9694 case wire.MsgAttachTarget:
9795 return wire.UnmarshalAttachTarget(frame.Payload)
9896 case wire.MsgRemotePreviewResponse:
@@ -111,14 +109,26 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
111109 return wire.UnmarshalRouteRetired(frame.Payload)
112110 case wire.MsgSamePeerSwitchFailure:
113111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
114- case wire.MsgParkedRouteResponse:
115- return wire.UnmarshalParkedRouteResponse(frame.Payload)
116112 case wire.MsgNavigationInventoryResponse:
117113 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
118114 case wire.MsgNavigationInventoryDemand:
119115 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
120116 case wire.MsgNavigationInventorySelection:
121117 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)
122132 case wire.MsgUIReceipt:
123133 return wire.UnmarshalUIReceipt(frame.Payload)
124134 case wire.MsgUIViewUpdate:
@@ -127,9 +137,10 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
127137 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,
128138 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
129139 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
130- wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
140+ wire.MsgSamePeerSwitchRequest,
131141 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:
133144 return nil, ErrWrongDirection
134145 default:
135146 return nil, ErrUnknownMessageType
@@ -189,12 +200,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
189200 case protocol.SamePeerSwitchRequest:
190201 payload, err := wire.MarshalSamePeerSwitchRequest(m)
191202 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
198203 case protocol.NavigationInventoryRequest:
199204 payload := wire.MarshalNavigationInventoryRequest(m)
200205 if payload == nil {
@@ -213,6 +218,36 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
213218 return wire.Frame{}, ErrInvalidMessage
214219 }
215220 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
216251 case protocol.RecentRouteSnapshot:
217252 payload, err := wire.MarshalRecentRouteSnapshot(m)
218253 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err
@@ -290,10 +325,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
290325 if m != nil {
291326 return encodeClient(*m)
292327 }
293- case *protocol.ParkedRouteRequest:
294- if m != nil {
295- return encodeClient(*m)
296- }
297328 case *protocol.NavigationInventoryRequest:
298329 if m != nil {
299330 return encodeClient(*m)
@@ -306,6 +337,26 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
306337 if m != nil {
307338 return encodeClient(*m)
308339 }
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+ }
309360 case *protocol.RecentRouteSnapshot:
310361 if m != nil {
311362 return encodeClient(*m)
M internal/adapters/sessionwire/client_test.go +0 -5
@@ -15,7 +15,6 @@ import (
1515 func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
1616 target := testTarget()
1717 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}
18- lease := protocol.ParkedRouteLeaseID{1}
1918 tests := []struct {
2019 name string
2120 message protocol.ClientMessage
@@ -39,7 +38,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
3938 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},
4039 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},
4140 {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},
4341 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},
4442 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},
4543 {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
@@ -66,7 +64,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
6664
6765 func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
6866 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
69- lease := protocol.ParkedRouteLeaseID{1}
7067 tests := []struct {
7168 name string
7269 message protocol.ServerMessage
@@ -78,7 +75,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
7875 {name: "pong", message: protocol.Pong{}},
7976 {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},
8077 {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},
81- {name: "navigation", message: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}},
8278 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
8379 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
8480 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},
@@ -87,7 +83,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
8783 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
8884 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},
8985 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},
90- {name: "parked response", message: protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}},
9186 }
9287 for _, tt := range tests {
9388 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) {
5959 failure.Kind = protocol.DecodeMessageNavigationInventory
6060 failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload)
6161 failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload)
62+ case wire.MsgPickerControlRequest:
63+ failure.Version, _ = wire.PeekPickerControlVersion(frame.Payload)
6264 }
6365 return nil, failure
6466 }
@@ -230,14 +232,22 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
230232 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)
231233 case wire.MsgSamePeerSwitchRequest:
232234 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
233- case wire.MsgParkedRouteRequest:
234- return wire.UnmarshalParkedRouteRequest(frame.Payload)
235235 case wire.MsgNavigationInventoryRequest:
236236 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
237237 case wire.MsgNavigationInventoryPublication:
238238 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
239239 case wire.MsgNavigationInventoryFailure:
240240 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)
241251 case wire.MsgRecentRouteSnapshot:
242252 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
243253 case wire.MsgRouteNavigationFailure:
@@ -247,12 +257,13 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
247257 case wire.MsgUIFence:
248258 return wire.UnmarshalUIFence(frame.Payload)
249259 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,
252261 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
253- wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse,
262+ wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure,
254263 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:
256267 return nil, ErrWrongDirection
257268 default:
258269 return nil, ErrUnknownMessageType
@@ -289,12 +300,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
289300 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil
290301 case protocol.CommandResult:
291302 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
298303 case protocol.AttachTarget:
299304 payload := wire.MarshalAttachTarget(m)
300305 if payload == nil {
@@ -328,12 +333,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
328333 case protocol.SamePeerSwitchFailure:
329334 payload, err := wire.MarshalSamePeerSwitchFailure(m)
330335 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
337336 case protocol.NavigationInventoryResponse:
338337 payload := wire.MarshalNavigationInventoryResponse(m)
339338 if payload == nil {
@@ -352,6 +351,48 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
352351 return wire.Frame{}, ErrInvalidMessage
353352 }
354353 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
355396 case *protocol.Welcome:
356397 if m != nil {
357398 return encodeServer(*m)
@@ -388,10 +429,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
388429 if m != nil {
389430 return encodeServer(*m)
390431 }
391- case *protocol.NavigationDirective:
392- if m != nil {
393- return encodeServer(*m)
394- }
395432 case *protocol.AttachTarget:
396433 if m != nil {
397434 return encodeServer(*m)
@@ -428,10 +465,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
428465 if m != nil {
429466 return encodeServer(*m)
430467 }
431- case *protocol.ParkedRouteResponse:
432- if m != nil {
433- return encodeServer(*m)
434- }
435468 case *protocol.NavigationInventoryResponse:
436469 if m != nil {
437470 return encodeServer(*m)
@@ -444,6 +477,34 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
444477 if m != nil {
445478 return encodeServer(*m)
446479 }
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+ }
447508 default:
448509 return wire.Frame{}, ErrWrongDirection
449510 }
M internal/adapters/sessionwire/server_test.go +0 -7
@@ -54,7 +54,6 @@ func testTarget() domain.RemoteSessionTarget {
5454 func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
5555 target := testTarget()
5656 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}
57- lease := protocol.ParkedRouteLeaseID{1}
5857 commandPayload, err := wire.MarshalCommandRequest(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"})
5958 require.NoError(t, err)
6059 resizePayload, err := wire.MarshalResize(protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}})
@@ -93,7 +92,6 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
9392 {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}},
9493 {name: "attention", frame: wire.Frame{Type: wire.MsgRouteAttentionSubscription, Payload: attentionPayload}, want: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}},
9594 {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}},
9795 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},
9896 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
9997 {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) {
110108
111109 func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
112110 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
113- lease := protocol.ParkedRouteLeaseID{1}
114111 welcome := protocol.Welcome{SessionID: "session"}
115112 errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}
116113 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) {
120117 pong := protocol.Pong{}
121118 sessions := protocol.Sessions{}
122119 commandResult := protocol.CommandResult{RequestID: 1, OK: true}
123- navigation := protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}
124120 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}
125121 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}
126122 identity := protocol.CommittedRouteIdentity{Target: exact}
@@ -129,7 +125,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
129125 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}
130126 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}
131127 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}
132- parkedResponse := protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}
133128
134129 outputPayload, err := wire.MarshalOutput(output)
135130 require.NoError(t, err)
@@ -167,7 +162,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
167162 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},
168163 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},
169164 {name: "command result", message: commandResult, typeID: wire.MsgCommandResult, payload: wire.MarshalCommandResult(commandResult)},
170- {name: "navigation", message: navigation, typeID: wire.MsgNavigationAction, payload: wire.MarshalNavigationDirective(navigation)},
171165 {name: "attach target", message: attachTarget, typeID: wire.MsgAttachTarget, payload: wire.MarshalAttachTarget(attachTarget)},
172166 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},
173167 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},
@@ -177,7 +171,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
177171 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},
178172 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},
179173 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},
180- {name: "parked response", message: parkedResponse, typeID: wire.MsgParkedRouteResponse, payload: wire.MarshalParkedRouteResponse(parkedResponse)},
181174 }
182175 for _, tt := range tests {
183176 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) {
5050 {"receipt", protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 1, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}},
5151 {"view update", protocol.UIViewUpdate{Epoch: 2, State: 1, Context: context}},
5252 {"attach target", protocol.AttachTarget{CauseActionID: 7, Session: "work", Intent: protocol.IntentAttach}},
53- {"navigation", protocol.NavigationDirective{CauseActionID: 7, Action: protocol.NavigationBack}},
5453 {"route action", protocol.RouteNavigationAction{CauseActionID: 7, SnapshotGeneration: 1, Key: 2, Generation: 3}},
5554 {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}},
5655 } {
M internal/adapters/uidriver/socket_test.go +7 -1
@@ -191,9 +191,15 @@ func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) {
191191 require.Zero(t, ready.ID)
192192 }
193193
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.
194198 func shortDir(t *testing.T) string {
195199 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")
197203 require.NoError(t, err)
198204 directory, err = filepath.Abs(directory)
199205 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 (
44 "context"
55 "testing"
66 "time"
7+
8+ "github.com/stretchr/testify/require"
79 )
810
911 // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the
@@ -28,3 +30,22 @@ func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) {
2830 t.Fatal("dial-only control dial without observer must also fail fast")
2931 }
3032 }
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) {
123123 outputs := make(chan string, 4)
124124 factory := &acceptanceRemoteFactory{outputs: outputs}
125125 hostStore := portsmocks.NewMockRemoteHostStore(t)
126- for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
127- hostStore.EXPECT().Remember(host).Return(nil).Once()
128- }
129126 localCalls := 0
130127 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
131128 deps := runAttachDeps{
M internal/app/remote_hosts.go +0 -24
@@ -258,27 +258,3 @@ func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.Remo
258258 }
259259 return out
260260 }
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) {
304304 }
305305 }
306306
307-func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
307+func TestRunAttachWithDepsDoesNotLearnDirectRemoteHost(t *testing.T) {
308308 store := portsmocks.NewMockRemoteHostStore(t)
309- store.EXPECT().Remember("build@mule").Return(nil).Once()
310309 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()
312311
313- var learner ports.RemoteHostLearner
314312 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
315313 remoteDialerFactory: factory.DialerForRemote,
316314 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 {
319316 return nil
320317 },
321318 })
322319 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())
344320 }
M internal/app/run.go +16 -51
@@ -1002,7 +1002,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10021002 localDialer = defaultLocalDialer
10031003 }
10041004 mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
1005- factory := deps.remoteDialerFactory
1005+ if deps.remoteDialerFactory == nil {
1006+ deps.remoteDialerFactory = defaultRemoteDialerFactory()
1007+ }
10061008 var remoteSelection *domain.RemoteSessionTarget
10071009 var remoteEnvironmentPolicy protocol.EnvironmentPolicy
10081010 remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget)
@@ -1014,9 +1016,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10141016 if modeErr != nil {
10151017 return modeErr
10161018 }
1017- if factory == nil {
1018- factory = defaultRemoteDialerFactory()
1019- }
10201019 }
10211020 pickerHandoff := remoteTarget == ""
10221021 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
10251024 }
10261025 return policy
10271026 }
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 = &copyTarget
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)
10641031
10651032 handoffAttempts := 0
10661033 for {
@@ -1069,12 +1036,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10691036 if log != nil {
10701037 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
10711038 }
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
10751044 }
10761045 err = runClient(ctx, client.Dependencies{
1077- Dialer: sessionwire.NewClientDialer(dialer),
1046+ Dialer: binding.Dialer,
10781047 LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
10791048 Terminal: clientTerminal(deps),
10801049 Clock: clientClock(deps),
@@ -1083,8 +1052,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10831052 Clipboard: deps.clipboard,
10841053 Logger: log,
10851054 RuntimeObserver: deps.runtimeObserver,
1086- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
1087- AttachHandoff: handoff,
1055+ HostRegistry: registry,
10881056 Remote: true,
10891057 Origin: protocol.RouteOriginRemote,
10901058 OriginKey: remoteTarget,
@@ -1092,7 +1060,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10921060 Intent: intent,
10931061 SessionName: name,
10941062 Remote: true,
1095- Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget),
1063+ Environment: binding.Environment,
10961064 Origin: routeOrigin,
10971065 OriginKey: routeOriginKey,
10981066 RemoteTarget: remoteSelection,
@@ -1113,7 +1081,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
11131081 Clipboard: deps.clipboard,
11141082 Logger: log,
11151083 RuntimeObserver: deps.runtimeObserver,
1116- AttachHandoff: handoff,
1084+ HostRegistry: registry,
11171085 Remote: false,
11181086 Origin: protocol.RouteOriginLocal,
11191087 OriginKey: "local",
@@ -1150,9 +1118,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
11501118 if modeErr != nil {
11511119 return modeErr
11521120 }
1153- if factory == nil {
1154- factory = defaultRemoteDialerFactory()
1155- }
11561121 }
11571122 }
11581123
M internal/app/run_test.go +40 -29
@@ -918,7 +918,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
918918 var gotClipboard ports.ClipboardReader
919919 clip := portsmocks.NewMockClipboardReader(t)
920920 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)
922922
923923 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
924924 remoteDialerFactory: factory.DialerForRemote,
@@ -954,33 +954,36 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
954954 }
955955 }
956956
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) {
958962 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()
961964
965+ var registryDials int
962966 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
963967 remoteDialerFactory: factory.DialerForRemote,
964968 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
965969 require.True(t, request.Remote)
966970 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++
974976 return nil
975977 },
976978 })
977979 require.NoError(t, err)
980+ require.Equal(t, 1, registryDials)
978981 }
979982
980983 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
981984 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()
984987 var calls int
985988 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
986989 remoteDialerFactory: factory.DialerForRemote,
@@ -999,29 +1002,37 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
9991002 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
10001003 }
10011004
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) {
10031010 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()
10061012
1013+ var first, second ports.ClientDialer
10071014 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
10081015 remoteDialerFactory: factory.DialerForRemote,
10091016 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()
10161023 return nil
10171024 },
10181025 })
10191026 require.NoError(t, err)
1027+ require.NotNil(t, first)
1028+ require.Equal(t, first, second, "revisiting an endpoint must reuse its binding")
10201029 }
10211030
10221031 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
10231032 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()
10251036
10261037 calls := 0
10271038 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
@@ -1047,7 +1058,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
10471058 } {
10481059 t.Run(tt.name, func(t *testing.T) {
10491060 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()
10511062 target := protocol.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: tt.intent, RequestID: tt.requestID, EnvironmentPolicy: tt.policy}
10521063 want := client.AttachRequest{Intent: tt.intent, SessionName: "picked", Remote: true, Origin: protocol.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
10531064 localCalls, remoteCalls := 0, 0
@@ -1062,9 +1073,9 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
10621073 }
10631074 localCalls++
10641075 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)
10681079 return &client.AttachTargetError{Target: target}
10691080 },
10701081 })
@@ -1098,7 +1109,7 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
10981109 for _, tt := range tests {
10991110 t.Run(tt.name, func(t *testing.T) {
11001111 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()
11021113 calls := 0
11031114 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
11041115 remoteDialerFactory: factory.DialerForRemote,
@@ -1136,7 +1147,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
11361147 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
11371148 factoryErr := errors.New("factory failed")
11381149 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)
11401151 runClientCalled := false
11411152
11421153 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) {
225225 t.Cleanup(func() { newPerformanceTrace = originalTrace })
226226
227227 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)
229229 originalFactory := newRemoteDialerFactoryWithRuntimeObserver
230230 factoryCalls := 0
231231 newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget {
M internal/app/ui_driver.go +8 -1
@@ -573,11 +573,18 @@ func clientClock(deps runAttachDeps) ports.Clock {
573573 return clock.New()
574574 }
575575
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.
576579 func clientEnvironment(factory func(string) []string, target string) []string {
577580 if factory == nil {
578581 return nil
579582 }
580- return append([]string(nil), factory(target)...)
583+ environment := factory(target)
584+ if environment == nil {
585+ return nil
586+ }
587+ return append([]string{}, environment...)
581588 }
582589
583590 func runUIDriver(ctx context.Context, options uiDriverOptions) error {
M internal/ports/mocks/remote_mock.go +187 -71
@@ -8,6 +8,7 @@ import (
88 "context"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/protocol/catalogue"
1314 mock "github.com/stretchr/testify/mock"
@@ -367,77 +368,6 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
367368 return _c
368369 }
369370
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-
441371 // 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.
442372 // The first argument is typically a *testing.T value.
443373 func NewMockRemoteCatalogClient(t interface {
@@ -768,3 +698,189 @@ func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalog
768698 _c.Call.Return(run)
769699 return _c
770700 }
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
365365 }
366366
367367 // 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.
373368 func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) {
374- _mock.Called(append([]byte(nil), data...))
369+ _mock.Called(data)
375370 return
376371 }
377372
M internal/ports/remote.go +22 -5
@@ -21,11 +21,6 @@ type RemoteHostStore interface {
2121 Remove(target string) (deleted bool, err error)
2222 }
2323
24-// RemoteHostLearner records the validated remote target after an attach.
25-type RemoteHostLearner interface {
26- RememberRemoteHost() error
27-}
28-
2924 // RemoteCatalogClient fetches a versioned session catalogue from a remote host.
3025 type RemoteCatalogClient interface {
3126 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
@@ -42,3 +37,25 @@ type RemoteCatalogCache interface {
4237 Load() ([]catalogue.RemoteCatalogCacheEntry, error)
4338 Store([]catalogue.RemoteCatalogCacheEntry) error
4439 }
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() {}
2222 func (RemotePreviewRequest) clientMessage() {}
2323 func (RouteAttentionSubscription) clientMessage() {}
2424 func (SamePeerSwitchRequest) clientMessage() {}
25-func (ParkedRouteRequest) clientMessage() {}
2625 func (RecentRouteSnapshot) clientMessage() {}
2726 func (RouteNavigationFailure) clientMessage() {}
2827 func (SessionCreationFailure) clientMessage() {}
@@ -30,6 +29,11 @@ func (UIFence) clientMessage() {}
3029 func (NavigationInventoryRequest) clientMessage() {}
3130 func (NavigationInventoryPublication) clientMessage() {}
3231 func (NavigationInventoryFailure) clientMessage() {}
32+func (PickerBegin) clientMessage() {}
33+func (PickerClose) clientMessage() {}
34+func (PickerSelection) clientMessage() {}
35+func (PickerPreviewRequest) clientMessage() {}
36+func (PickerControlRequest) clientMessage() {}
3337
3438 func (Welcome) serverMessage() {}
3539 func (ErrorMsg) serverMessage() {}
@@ -38,7 +42,6 @@ func (Detached) serverMessage() {}
3842 func (Pong) serverMessage() {}
3943 func (Sessions) serverMessage() {}
4044 func (CommandResult) serverMessage() {}
41-func (NavigationDirective) serverMessage() {}
4245 func (AttachTarget) serverMessage() {}
4346 func (RemotePreview) serverMessage() {}
4447 func (CommittedRouteIdentity) serverMessage() {}
@@ -48,9 +51,15 @@ func (RouteNavigationFailure) serverMessage() {}
4851 func (RoutePosition) serverMessage() {}
4952 func (RouteRetired) serverMessage() {}
5053 func (SamePeerSwitchFailure) serverMessage() {}
51-func (ParkedRouteResponse) serverMessage() {}
5254 func (UIReceipt) serverMessage() {}
5355 func (UIViewUpdate) serverMessage() {}
5456 func (NavigationInventoryResponse) serverMessage() {}
5557 func (NavigationInventoryDemand) serverMessage() {}
5658 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 @@
11 package protocol
22
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.
57 type NavigationCapabilities uint8
68
79 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
6111 )
6212
63-type ParkedRouteResponse struct {
64- RequestID uint64
65- Status ParkedRouteStatus
66-}
67-
6813 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
7415 }
7516
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) {
8623 return ErrInvalidNavigation
8724 }
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:
9927 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 {
11530 return ErrInvalidNavigation
11631 }
117- default:
118- return ErrInvalidNavigation
32+ return nil
11933 }
120- return nil
12134 }
M internal/protocol/navigation_inventory_test.go +28 -1
@@ -64,7 +64,34 @@ func TestValidateNavigationInventoryResponseUnions(t *testing.T) {
6464 }
6565
6666 func TestNavigationCapabilityInventoryValid(t *testing.T) {
67- if err := ValidateNavigation(NavigationCapabilityInventory, StartupOverlayNone, false); err != nil {
67+ if err := ValidateNavigation(IntentAttach, NavigationCapabilityInventory); err != nil {
6868 t.Fatalf("ValidateNavigation(inventory) = %v", err)
6969 }
7070 }
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
33 import (
44 "errors"
55 "math"
6- "unicode"
7- "unicode/utf8"
86
97 renderer "github.com/bnema/vev-vt"
108 "github.com/bnema/vev/internal/domain"
@@ -97,25 +95,13 @@ func ValidateRemotePreview(preview RemotePreview) error {
9795 if want > RemotePreviewMaxCells || len(preview.Cells) != want {
9896 return ErrRemotePreviewTooLarge
9997 }
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
114100 }
115101 return nil
116102 }
117103
118-func validRemotePreviewStyle(style renderer.Style) bool {
104+func validPreviewCellStyle(style renderer.Style) bool {
119105 return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 &&
120106 style.UnderlineStyle <= renderer.UnderlineDashed &&
121107 style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 &&
M internal/protocol/session.go +2 -3
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 43
16+const Version uint16 = 51
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
@@ -113,7 +113,6 @@ type Hello struct {
113113 PreferredTabID domain.TabStableID
114114 EnvironmentPolicy EnvironmentPolicy
115115 NavigationCapabilities NavigationCapabilities
116- StartupOverlay StartupOverlay
117116 Remote bool
118117 }
119118
@@ -368,7 +367,7 @@ func ValidateHello(h Hello) error {
368367 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
369368 return ErrInvalidHello
370369 }
371- if err := validateHelloNavigation(h); err != nil {
370+ if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
372371 return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
373372 }
374373 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 (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(43), Version)
13+ require.Equal(t, uint16(51), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -24,7 +24,7 @@ func TestHelloSemanticValidation(t *testing.T) {
2424 {name: "valid", mutate: func(*Hello) {}},
2525 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},
2626 {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},
2828 {name: "exact target name mismatch", mutate: func(h *Hello) {
2929 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}
3030 }, want: ErrInvalidHello},
@@ -85,5 +85,4 @@ func TestRouteAndPreviewSemanticValidation(t *testing.T) {
8585 func TestSemanticErrorsPreserveClassification(t *testing.T) {
8686 require.True(t, errors.Is(ValidateAck(Ack{}), ErrInvalidAck))
8787 require.True(t, errors.Is(ValidateAttachTarget(AttachTarget{}), ErrInvalidAttachTarget))
88- require.True(t, errors.Is(ValidateParkedRouteRequest(ParkedRouteRequest{}), ErrInvalidNavigation))
8988 }
M internal/protocol/wire/frame.go +17 -5
@@ -9,8 +9,11 @@ type MsgType uint8
99
1010 // Frame message types use non-contiguous allocations: the legacy client range
1111 // 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.
1417 const (
1518 MsgHello MsgType = 1
1619 MsgInput MsgType = 2
@@ -28,12 +31,16 @@ const (
2831 MsgRemotePreviewRequest MsgType = 15
2932 MsgRouteAttentionSubscription MsgType = 32
3033 MsgSamePeerSwitchRequest MsgType = 33
31- MsgParkedRouteRequest MsgType = 35
3234 MsgSessionCreationFailure MsgType = 37
3335 MsgUIFence MsgType = 39
3436 MsgNavigationInventoryRequest MsgType = 42
3537 MsgNavigationInventoryPublication MsgType = 45
3638 MsgNavigationInventoryFailure MsgType = 47
39+ MsgPickerCloseClient MsgType = 51
40+ MsgPickerSelection MsgType = 53
41+ MsgPickerBegin MsgType = 55
42+ MsgPickerPreviewRequest MsgType = 57
43+ MsgPickerControlRequest MsgType = 60
3744
3845 MsgWelcome MsgType = 16
3946 MsgError MsgType = 17
@@ -42,7 +49,6 @@ const (
4249 MsgPong MsgType = 20
4350 MsgSessions MsgType = 21
4451 MsgCommandResult MsgType = 22
45- MsgNavigationAction MsgType = 23
4652 MsgAttachTarget MsgType = 25
4753 MsgRemotePreviewResponse MsgType = 26
4854 MsgCommittedRouteIdentity MsgType = 27
@@ -51,7 +57,6 @@ const (
5157 MsgRouteNavigationFailure MsgType = 30
5258 MsgRoutePosition MsgType = 31
5359 MsgSamePeerSwitchFailure MsgType = 34
54- MsgParkedRouteResponse MsgType = 36
5560 MsgRouteCreateSession MsgType = 38
5661 MsgUIReceipt MsgType = 40
5762 MsgUIViewUpdate MsgType = 41
@@ -59,6 +64,13 @@ const (
5964 MsgNavigationInventoryDemand MsgType = 44
6065 MsgNavigationInventorySelection MsgType = 46
6166 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
6274 )
6375
6476 // 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 (
77 )
88
99 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)
1212 }
1313 }
1414
@@ -25,9 +25,6 @@ func TestControlMsgTypes(t *testing.T) {
2525 }
2626
2727 func TestMsgTypeUnique(t *testing.T) {
28- if MsgNavigationAction != 23 {
29- t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction)
30- }
3128 tests := []struct {
3229 name string
3330 typ MsgType
@@ -37,14 +34,19 @@ func TestMsgTypeUnique(t *testing.T) {
3734 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3835 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
3936 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
40- {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},
37+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},
4138 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
4239 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
4340 {"MsgCommandResult", MsgCommandResult},
44- {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
41+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},
4542 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
4643 {"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},
4850 }
4951
5052 seen := make(map[MsgType]string, len(tests))
M internal/protocol/wire/navigation_test.go +8 -148
@@ -1,7 +1,6 @@
11 package wire
22
33 import (
4- "encoding/hex"
54 "testing"
65
76 "github.com/bnema/vev/internal/domain"
@@ -9,129 +8,6 @@ import (
98 "github.com/stretchr/testify/require"
109 )
1110
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-
13511 func TestHelloNavigationValidationTable(t *testing.T) {
13612 base := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
13713 remoteTarget := &domain.RemoteSessionTarget{
@@ -139,26 +15,18 @@ func TestHelloNavigationValidationTable(t *testing.T) {
13915 SessionName: "work", LiveTabID: "tab-1",
14016 }
14117 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
14822 }{
14923 {name: "ordinary", hello: base, valid: true},
15024 {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},
15929 {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},
16230 }
16331
16432 for _, tt := range tests {
@@ -168,17 +36,9 @@ func TestHelloNavigationValidationTable(t *testing.T) {
16836 require.NoError(t, err)
16937 payload := MarshalHello(tt.hello)
17038 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- }
17839 decoded, decodeErr := UnmarshalHello(payload)
17940 require.NoError(t, decodeErr)
18041 require.Equal(t, tt.capability, decoded.NavigationCapabilities)
181- require.Equal(t, tt.overlay, decoded.StartupOverlay)
18242 return
18343 }
18444 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 {
462462 marshalExactTargetSection(&w, h.ExactTarget)
463463 w.putString(string(h.PreferredTabID))
464464 w.putUint8(uint8(h.NavigationCapabilities))
465- w.putUint8(uint8(h.StartupOverlay))
466465 w.putBool(h.Remote)
467466 w.putBool(h.KittyDirectGraphics)
468467 return w.b
@@ -538,9 +537,6 @@ func preflightHello(b []byte) error {
538537 if _, err := r.getUint8(); err != nil {
539538 return err
540539 }
541- if _, err := r.getUint8(); err != nil {
542- return err
543- }
544540 if _, err := r.getBool(); err != nil {
545541 return err
546542 }
@@ -645,11 +641,6 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) {
645641 return protocol.Hello{}, err
646642 }
647643 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)
653644 if h.Remote, err = r.getBool(); err != nil {
654645 return protocol.Hello{}, err
655646 }
@@ -1661,13 +1652,7 @@ func MarshalRemotePreview(preview protocol.RemotePreview) []byte {
16611652 w.putUint16(preview.Height)
16621653 w.putUint32(uint32(len(preview.Cells)))
16631654 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)
16711656 }
16721657 return w.b
16731658 }
@@ -1716,22 +1701,14 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) {
17161701 if count != 0 {
17171702 p.Cells = make([]renderer.Cell, 0, int(count))
17181703 for range int(count) {
1719- runeValue, e := r.getUint32()
1704+ cell, e := getPreviewCell(&r)
17201705 if e != nil {
1706+ if errors.Is(e, errPreviewCellFlags) {
1707+ return p, protocol.ErrInvalidRemotePreview
1708+ }
17211709 return p, e
17221710 }
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)
17351712 }
17361713 }
17371714 if err := r.done(); err != nil {
@@ -1742,116 +1719,3 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) {
17421719 }
17431720 return p, nil
17441721 }
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 (
1212 )
1313
1414 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)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,9 +27,9 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 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)...)
3131 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)
3333 got := MarshalHello(msg)
3434 if !bytes.Equal(got, want) {
3535 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
5656
5757 func appendNoNavigationTail(payload []byte) []byte {
5858 // 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)
6161 }
6262
6363 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x2b, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x33, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(43), protocol.Version)
370+ require.Equal(t, uint16(51), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x2b,
531+ 0x00, 0x33,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x2b,
554+ 0x00, 0x33,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999999 name: "attached",
10001000 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
10011001 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,
10031003 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
10041004 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
10051005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -1009,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
10091009 name: "control",
10101010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10111011 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,
10131013 0x00, 0x02, 'l', 's', 0x00, 0x00,
10141014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10151015 },
M internal/usecase/client/client.go +906 -430
@@ -136,11 +136,6 @@ const sendQueueDepth = 64
136136 // budget, which now also covers connect and initial publication.
137137 const preWelcomeTimeout = protocol.HandshakeTimeout
138138
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-
144139 const kittyCapabilityProbeTimeout = 150 * time.Millisecond
145140
146141 var reconnectSleep = sleepReconnect
@@ -152,8 +147,6 @@ const (
152147 )
153148
154149 // Dependencies supplies the collaborators required by a Runner.
155-type AttachHandoffFunc func(protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error)
156-
157150 type Dependencies struct {
158151 Dialer ports.ClientDialer
159152 Terminal ports.Terminal
@@ -165,10 +158,10 @@ type Dependencies struct {
165158 Clipboard ports.ClipboardReader
166159 Logger *slog.Logger
167160 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
172165 // LocalControlDialer is an optional dial-only control source for the
173166 // local daemon inventory. It must never start the daemon, create an
174167 // attachment, or call the CLI: a missing socket reports local control
@@ -205,7 +198,69 @@ type AttachRequest struct {
205198 // environment and is supplied only by explicit composition.
206199 Environment []string
207200 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
209264 }
210265
211266 // attachRoute captures the dialer, request, and resume token needed to
@@ -236,21 +291,26 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach
236291 }
237292
238293 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
249308 // localControlDialer is the dial-only inventory source. It never
250309 // starts the daemon; the relay is its sole consumer.
251310 localControlDialer ports.ClientDialer
252311 origin protocol.RouteOrigin
253312 ledger *routeLedger
313+ routeObservers *routeObserverRegistry
254314 routeFailure *protocol.RouteNavigationFailure
255315 creationFailure *protocol.SessionCreationFailure
256316 // inventoryFailure reports a failed inventory handoff to the restored
@@ -279,13 +339,14 @@ func NewRunner(deps Dependencies) *Runner {
279339 clipboard: deps.Clipboard,
280340 logger: log,
281341 runtimeObserver: deps.RuntimeObserver,
282- remoteHostLearner: deps.RemoteHostLearner,
283- attachHandoff: deps.AttachHandoff,
342+ hostRegistry: deps.HostRegistry,
343+ launchedRemote: deps.Remote,
284344 remote: deps.Remote,
285345 localControlDialer: deps.LocalControlDialer,
286346 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
287347 probeCapabilities: !deps.DisableCapabilityProbe,
288348 ledger: newRouteLedger(),
349+ routeObservers: newRouteObserverRegistry(),
289350 }
290351 }
291352
@@ -437,8 +498,7 @@ func validateAttachRequest(request AttachRequest) error {
437498 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
438499 return fmt.Errorf("vev: invalid session target: %w", err)
439500 }
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 {
442502 return fmt.Errorf("vev: invalid navigation route: %w", err)
443503 }
444504 if request.RemoteTarget == nil {
@@ -513,9 +573,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
513573 attemptRequest := request
514574 dialer := r.dialer
515575 var homeRoute *attachRoute
516- var returnRoute *attachRoute
517576 returnResumeFallback := false
518- homeNavigationPending := false
519577 returnNavigationPending := false
520578 // navTransition owns creation and recent-route fallback lifecycle:
521579 // 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)
533591 var routeNavigationAction *protocol.RouteNavigationAction
534592 backoff := defaultReconnectBackoff.initial
535593 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- }()
569594 defer func() {
570595 if rawEntered {
571596 if rerr := restore(); rerr != nil && retErr == nil {
@@ -594,26 +619,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
594619 } else {
595620 attemptRequest.Intent = protocol.IntentAttach
596621 }
597- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
598- attemptRequest.NavigationCapabilities &^= protocol.NavigationCapabilityBack | protocol.NavigationCapabilityHomePicker
599622 resumeToken = route.resumeToken
600623 returnResumeFallback = route.resumeToken != 0
601624 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
602625 backoff = defaultReconnectBackoff.initial
603626 }
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- }
617627 var input *terminalInputPump
618628 defer func() {
619629 if input != nil {
@@ -652,59 +662,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
652662 return true
653663 }
654664
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-
708665 for {
709666 if err := validateAttachRequest(attemptRequest); err != nil {
710667 return err
@@ -841,11 +798,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
841798 reconnect: reconnect,
842799 transition: transition,
843800 linkEvents: linkEvents,
844- rememberRemoteHost: rememberRemoteHost,
845801 terminalInput: func() *terminalInputPump {
846802 return input
847803 },
848- openHomePicker: runTransientHomePicker,
849804 inventoryHome: homeRoute,
850805 inventoryDialer: r.localControlDialer,
851806 onInventoryCommitted: func() {
@@ -906,6 +861,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
906861 if result.err == nil && result.welcomed && navTransition.pendingInventory() {
907862 navTransition.settleSuccess()
908863 }
864+ if result.err == nil && result.welcomed {
865+ r.routeObservers.register(attemptRequest, dialer)
866+ }
909867 if result.routeCreateAction != nil {
910868 action := *result.routeCreateAction
911869 source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}
@@ -931,7 +889,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
931889 attemptRequest.ExactTarget = nil
932890 attemptRequest.RemoteTarget = nil
933891 attemptRequest.PreferredTabID = ""
934- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
935892 attemptRequest.NavigationCapabilities = 0
936893 attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
937894 resumeToken = 0
@@ -961,14 +918,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
961918 } else {
962919 attemptRequest.Intent = protocol.IntentAttach
963920 }
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
968921 attemptRequest.NavigationCapabilities = 0
969- if homeRoute != nil && selection.selected.presentation.kind == protocol.RouteKindRemote {
970- attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityHomePicker
971- }
972922 resumeToken = selection.selected.resumeToken
973923 routeNavigationSelection = &selection
974924 routeNavigationResumeFallback = selection.selected.resumeToken != 0
@@ -976,41 +926,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
976926 backoff = defaultReconnectBackoff.initial
977927 continue
978928 }
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- }
1014929 if result.target != nil || result.handoff != nil {
1015930 target := result.target
1016931 source := attachRoute{dialer: dialer, request: attemptRequest, resumeToken: resumeToken}
@@ -1026,7 +941,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1026941 }
1027942 routeRequest.Intent = protocol.IntentAttach
1028943 routeRequest.NavigationCapabilities = 0
1029- routeRequest.StartupOverlay = protocol.StartupOverlayNone
1030944 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
1031945 }
1032946 }
@@ -1042,10 +956,12 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1042956 nextDialer = source.dialer
1043957 nextRequest = r.ledger.samePeerHandoff(source.request, *target)
1044958 } 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.
1046962 return &AttachTargetError{Target: *target}
1047963 }
1048- nextDialer, nextRequest, handoffErr = r.attachHandoff(*target)
964+ nextDialer, nextRequest, handoffErr = r.resolveHandoff(ctx, *target)
1049965 if handoffErr != nil {
1050966 if target.Intent == protocol.IntentNew && target.RequestID != 0 {
1051967 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)
1059975 if target.ExactTarget != nil {
1060976 nextRequest.ExactTarget = target.ExactTarget
1061977 }
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- }
1071978 nextRequest = cloneAttachRequest(nextRequest)
1072979 if err := validateAttachRequest(nextRequest); err != nil {
1073980 if target.Intent == protocol.IntentNew && target.RequestID != 0 {
@@ -1125,7 +1032,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
11251032 killedSelection = &selection
11261033 dialer = selection.selected.dialer
11271034 attemptRequest = cloneAttachRequest(selection.selected.request)
1128- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
11291035 attemptRequest.NavigationCapabilities = 0
11301036 resumeToken = selection.selected.resumeToken
11311037 killedResumeFallback = resumeToken != 0
@@ -1135,8 +1041,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
11351041 attemptRequest.Intent = protocol.IntentAttach
11361042 }
11371043 homeRoute = nil
1138- returnRoute = nil
1139- homeNavigationPending = false
11401044 returnNavigationPending = false
11411045 if navTransition.operation == navigationOperationRecent {
11421046 navTransition.clear()
@@ -1156,17 +1060,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
11561060 }
11571061 return errors.Join(result.err, r.ledger.retireKilled(*killedSelection))
11581062 }
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- }
11701063 if navTransition.pendingCreation() {
11711064 r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)}
11721065 route, ok := navTransition.restore()
@@ -1213,12 +1106,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
12131106 returnResumeFallback = false
12141107 continue
12151108 }
1216- if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
1217- returnRoute = nil
1218- returnNavigationPending = false
1219- enterHomePicker()
1220- continue
1221- }
12221109 if attemptRequest.Intent == protocol.IntentResume && !result.welcomed &&
12231110 attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) {
12241111 attemptRequest.Intent = protocol.IntentAttach
@@ -1397,9 +1284,7 @@ type attachAttempt struct {
13971284 reconnect *reconnectUI
13981285 transition *transitionUI
13991286 linkEvents <-chan ports.LinkEvent
1400- rememberRemoteHost func()
14011287 terminalInput func() *terminalInputPump
1402- openHomePicker func(context.Context) attachResult
14031288 // inventoryHome is the committed local route that authorises the
14041289 // navigation-inventory relay and its handoffs. Nil disables the relay:
14051290 // local-only and ledger-less attachments never dial control sources.
@@ -1425,7 +1310,6 @@ type attachResult struct {
14251310 transportClosed bool
14261311 target *protocol.AttachTarget
14271312 handoff *attachHandoff
1428- action protocol.NavigationAction
14291313 routeAction *protocol.RouteNavigationAction
14301314 routeCreateAction *protocol.RouteCreateSessionAction
14311315 err error
@@ -1473,9 +1357,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14731357 transport = a.connection.Connection()
14741358 }
14751359 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
14791360 term := a.runner.term
14801361 clk := a.runner.clock
14811362 intent := request.Intent
@@ -1496,10 +1377,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14961377 // resolve channel. Advertising the capability in Hello is what lets
14971378 // the serving palette send demands at all.
14981379 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) {
15001382 inventory = newInventoryRelay(clk, a.inventoryDialer)
1383+ hybridPicker = &pickerControl{dialer: a.inventoryDialer}
15011384 request.NavigationCapabilities |= protocol.NavigationCapabilityInventory
15021385 }
1386+ // The client-picker loop owns navigate-intent presentation while the
15031387 clipboard := a.runner.clipboard
15041388 log := a.runner.logger
15051389 observer := a.runner.runtimeObserver
@@ -1589,7 +1473,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15891473 PreferredTabID: request.PreferredTabID,
15901474 EnvironmentPolicy: request.EnvironmentPolicy,
15911475 NavigationCapabilities: request.NavigationCapabilities,
1592- StartupOverlay: request.StartupOverlay,
15931476 Remote: remote,
15941477 }
15951478 if err := sendHandshake(func() error {
@@ -1628,11 +1511,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16281511 committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
16291512 ms.welcomed = true
16301513 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
1631- if remote {
1632- if remember := a.rememberRemoteHost; remember != nil {
1633- remember()
1634- }
1635- }
16361514 case protocol.ErrorMsg:
16371515 return result(false, &ProtocolError{Code: message.Code, Text: message.Text})
16381516 default:
@@ -1652,7 +1530,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16521530 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
16531531 return welcomedResult(errRouteTargetChanged)
16541532 }
1655- if !transientPicker {
1533+ {
16561534 candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
16571535 var commitErr error
16581536 if a.killedSelection != nil {
@@ -1717,11 +1595,113 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17171595 // completions carry their originating interaction and query identity.
17181596 // The relay itself is created before Hello so the capability advertises.
17191597 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())
17201606 var inventoryResolveC chan inventoryResolveOutcome
17211607 inventoryResolvePending := false
17221608 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+ }
17231628 inventoryOpen := false
17241629 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+ }
17251705 var inventoryTimer ports.Timer
17261706 var inventoryTickC <-chan time.Time
17271707 stopInventoryTimer := func() {
@@ -1751,6 +1731,63 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17511731 }
17521732 }
17531733 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+ }
17541791 startInventoryPoll := func() {
17551792 if inventory == nil || !inventoryOpen {
17561793 return
@@ -1908,17 +1945,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
19081945 transitionWaitingFull := false
19091946 var samePeerSwitch *samePeerSwitchPending
19101947 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
19221948 transportFailed := make(chan struct{})
19231949 if reconnect.showing {
19241950 if reconnect.remote {
@@ -1960,6 +1986,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
19601986 stdinDone, resizeDone chan struct{}
19611987 }
19621988 var foreground *foregroundRuntime
1989+ resizeEvents := make(chan domain.Geometry, 1)
19631990 startForeground := func() {
19641991 if foreground != nil {
19651992 return
@@ -1983,11 +2010,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
19832010 foregroundCancel()
19842011 cancel()
19852012 }
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()
19872014 }()
19882015 go func() {
19892016 defer close(resizeDone)
1990- runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)
2017+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, resizeEvents, sendLease, log)
19912018 }()
19922019 }
19932020 stopForeground := func() {
@@ -2008,144 +2035,190 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20082035 cancelTimer(completionTimers, id)
20092036 }
20102037 }
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 {
20202045 return nil
2021- case <-loopCtx.Done():
2022- return context.Canceled
20232046 }
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+ }
20302054 }
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)
20402059 }
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)
20472062 }
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
20492073 }
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 {
20522082 return nil
20532083 }
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)
20622087 }
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
20672101 }
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}
20682111 select {
2069- case controlCh <- message:
2112+ case controlCh <- closeMsg:
20702113 return nil
20712114 case <-loopCtx.Done():
20722115 return context.Canceled
20732116 }
20742117 }
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
20782124 }
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
20832127 }
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()
20902134 return nil
2091- case <-loopCtx.Done():
2092- return context.Canceled
20932135 }
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
21122141 }
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
21162161 }
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()
21242178 }
2125- fallback := *selection.handoff
2126- parkedPending.fallback = &fallback
2127- return attachResult{}, false
21282179 }
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
21312194 }
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
21342198 }
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{})
21372202 }
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)
21402214 }
2141- return attachResult{}, false
21422215 }
21432216 startForeground()
21442217 defer stopForeground()
21452218
2146- // 5. Output/main loop: the only goroutine that touches the terminal.
21472219 recvCh := make(chan recvResult, 1)
21482220 go runRecv(loopCtx, transport, recvCh, transportFailed, log)
2221+ armRouteObservation()
21492222
21502223 requestReconnectReset := func() error {
21512224 if awaitingReconnectReset {
@@ -2235,19 +2308,192 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
22352308 select {
22362309 case <-loopCtx.Done():
22372310 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+ }
22382439 case <-transition.tickC():
22392440 if err := transition.advance(); err != nil {
22402441 return welcomedResult(err)
22412442 }
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"))
22482443 case <-inventoryTickC:
22492444 startInventoryPoll()
22502445 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+ }
22512497 case <-inventoryBox.wake:
22522498 pollOutcome, ok := inventoryBox.take()
22532499 if !ok {
@@ -2443,10 +2689,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
24432689 }
24442690 continue
24452691 }
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
24482707 }
2449- if ui != nil && o.Full && foreground == nil && (parkedWaitingFull || samePeerSwitch == nil) {
2708+ if ui != nil && o.Full && foreground == nil && samePeerSwitch == nil {
24502709 startForeground()
24512710 }
24522711 if uiOutput != nil {
@@ -2502,10 +2761,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
25022761 transitionWaitingFull = false
25032762 transition.stop()
25042763 }
2505- if parkedWaitingFull {
2506- clearParkedFull()
2507- startForeground()
2508- }
25092764 if o.New != 0 {
25102765 ackQueue.offer(o.Epoch, o.New)
25112766 outputResetRequested = false
@@ -2519,6 +2774,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
25192774 log.Debug("received first output")
25202775 endHandshake()
25212776 }
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+ }
25222791 case protocol.UIViewUpdate:
25232792 nextState, accepted, needsReset := outputState.nextView(message)
25242793 if needsReset && !outputResetRequested {
@@ -2552,11 +2821,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
25522821 publishUIStatus(ports.UIStatusTransitioning)
25532822 }
25542823 target := message
2555- if transientPicker {
2556- handoff := welcomedResult(nil)
2557- handoff.target = &target
2558- return handoff
2559- }
25602824 if transition != nil {
25612825 transitionWaitingFull = false
25622826 transition.start(target)
@@ -2593,72 +2857,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
25932857 handoff := welcomedResult(nil)
25942858 handoff.target = &target
25952859 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
26622860 case protocol.NavigationInventoryDemand:
26632861 demand := message
26642862 if inventory == nil {
@@ -2718,6 +2916,164 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
27182916 // Overlay-owned liveness. The relay never waits on it:
27192917 // publications carry the only state it tracks.
27202918 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
27213077 case protocol.RouteCreateSessionAction:
27223078 action := message
27233079 if action.Validate() != nil {
@@ -2774,9 +3130,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
27743130 }
27753131 name = identity.Target.SessionName
27763132 committedIdentity = cloneCommittedIdentity(&identity)
2777- if transientPicker {
2778- continue
2779- }
27803133 if a.runner.ledger == nil {
27813134 return welcomedResult(errors.New("vev: route ledger unavailable"))
27823135 }
@@ -3084,6 +3437,11 @@ type terminalReadResult struct {
30843437 generation uint64
30853438 actionID uint64
30863439 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
30873445 }
30883446
30893447 // terminalAutomationRequest is admitted only by the foreground scanner owner.
@@ -3456,6 +3814,12 @@ type stdinPump struct {
34563814 afterInputTake func() // test synchronization hook
34573815 afterInputDelivered func() // test synchronization hook
34583816 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
34593823 }
34603824
34613825 func (p *stdinPump) run() {
@@ -3538,6 +3902,7 @@ func (p *stdinPump) run() {
35383902 sink := coalescer.Scan
35393903 if p.clipboard != nil {
35403904 ci := &clipboardIntercept{
3905+ ctx: p.ctx,
35413906 coalescer: coalescer,
35423907 reader: p.clipboard,
35433908 log: p.logger,
@@ -3552,6 +3917,20 @@ func (p *stdinPump) run() {
35523917 }
35533918 sink = ci.Scan
35543919 }
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+ }
35553934 input := p.input
35563935 ownedInput := input == nil
35573936 if ownedInput {
@@ -3587,7 +3966,7 @@ func (p *stdinPump) run() {
35873966 var markerTimer ports.Timer
35883967 var markerTimerC <-chan time.Time
35893968 flushMarkerPrefix := func() {
3590- markers.flush(sink)
3969+ markers.flush(routeInput)
35913970 markerTimer = nil
35923971 markerTimerC = nil
35933972 }
@@ -3621,7 +4000,61 @@ func (p *stdinPump) run() {
36214000 }
36224001 defer disarmMarkerDeadline()
36234002
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+
36244050 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()
36254058 var result terminalReadResult
36264059 var batch *terminalAutomationRequest
36274060 select {
@@ -3630,6 +4063,11 @@ func (p *stdinPump) run() {
36304063 case <-markerTimerC:
36314064 flushMarkerPrefix()
36324065 continue
4066+ case <-pickerTimerC:
4067+ disarmPickerDeadline()
4068+ if outcome, ok := p.picker.flushPending(); ok {
4069+ p.pickerOutcomes.offer(outcome)
4070+ }
36334071 case request := <-input.automation:
36344072 input.mu.Lock()
36354073 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() {
36474085 activeBatch = batch
36484086 batchSent.Store(false)
36494087 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+ }
36504109 result = request.record
36514110 automated = true
36524111 actionID.Store(result.actionID)
@@ -3693,7 +4152,7 @@ func (p *stdinPump) run() {
36934152 }, func(light bool) {
36944153 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
36954154 }, func(data []byte) {
3696- markers.scan(data, sink, func() {
4155+ markers.scan(data, routeInput, func() {
36974156 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
36984157 })
36994158 })
@@ -3708,9 +4167,9 @@ func (p *stdinPump) run() {
37084167 }
37094168 if batch != nil {
37104169 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}) })
37124171 })
3713- markers.flush(sink)
4172+ markers.flush(routeInput)
37144173 ok := coalescer.EndBatch() && sendOK.Load()
37154174 batchComplete = ok && pendingEvents.Load() == 0
37164175 applied := make(chan struct{})
@@ -3744,7 +4203,7 @@ func (p *stdinPump) run() {
37444203 }
37454204 if result.err != nil {
37464205 disarmMarkerDeadline()
3747- markers.flush(sink)
4206+ markers.flush(routeInput)
37484207 select {
37494208 case p.out <- protocol.Detach{}:
37504209 case <-p.ctx.Done():
@@ -3763,10 +4222,26 @@ func (p *stdinPump) run() {
37634222 }
37644223 }
37654224
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+
37664241 // runResize forwards coalesced terminal resize events to the daemon. It
37674242 // tolerates an already-closed resize channel (which the terminal adapter
37684243 // 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) {
37704245 defer log.Debug("resize pump exited")
37714246 for {
37724247 select {
@@ -3783,6 +4258,7 @@ func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- pr
37834258 if !sendLease.send(func() bool {
37844259 select {
37854260 case out <- message:
4261+ offerLatestGeometry(local, geometry)
37864262 return true
37874263 case <-ctx.Done():
37884264 return false
M internal/usecase/client/client_test.go +171 -1133
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "io"
89 "log/slog"
910 "os"
@@ -239,6 +240,64 @@ func isType(typ wire.MsgType) any {
239240 return mock.MatchedBy(func(f wire.Frame) bool { return f.Type == typ })
240241 }
241242
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+
242301 // transportDialer adapts one already-open test transport to the Runner API.
243302 type transportDialer struct{ transport wire.Transport }
244303
@@ -557,6 +616,66 @@ func TestKilledSessionReturnsToPreviousLocalRoute(t *testing.T) {
557616 require.Equal(t, int32(1), term.restoreCount.Load())
558617 }
559618
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+
560679 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
561680 term := newRunTerminal()
562681 defer term.in.unblock()
@@ -568,8 +687,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
568687 request := client.AttachRequest{
569688 Intent: protocol.IntentAttach, SessionName: "work", Remote: true,
570689 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
571- NavigationCapabilities: protocol.NavigationCapabilityHomePicker,
572- StartupOverlay: protocol.StartupOverlayNone,
690+ NavigationCapabilities: protocol.NavigationCapabilityInventory,
573691 }
574692 tr := &recordingTransport{recvs: []recvItem{
575693 {f: welcomeFrame(0)},
@@ -582,8 +700,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
582700 require.Equal(t, request.SessionName, hello.Name)
583701 require.Equal(t, target, hello.RemoteTarget)
584702 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)
587704 }
588705
589706 func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
@@ -625,56 +742,6 @@ func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
625742 require.Equal(t, &target, helloFromSend(t, second).ExactTarget)
626743 }
627744
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-
678745 func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
679746 return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
680747 SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume,
@@ -694,136 +761,10 @@ func hybridLocalBootstrap(lifecycle domain.SessionLifecycleID, target domain.Rem
694761
695762 func hybridPickerDependencies(localDialer ports.ClientDialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.ClientDialer) client.Dependencies {
696763 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)
712765 return deps
713766 }
714767
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-
827768 func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) {
828769 term := newRunTerminal()
829770 defer term.in.unblock()
@@ -879,381 +820,10 @@ func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) {
879820 require.Equal(t, int32(1), remoteDialer.calls.Load(), "BCK must retain the authenticated remote transport")
880821 }
881822
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.
1257827
1258828 func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1259829 tests := []struct {
@@ -1286,17 +856,15 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1286856 sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}}
1287857 targetDialer := &sequenceDialer{errs: []error{tt.dialErr}}
1288858 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)
1291863 if tt.handoffErr != nil {
1292- return nil, client.AttachRequest{}, tt.handoffErr
864+ return ports.RemoteEndpointBinding{}, tt.handoffErr
1293865 }
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+ }}
1300868
1301869 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1302870 Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local",
@@ -1349,138 +917,41 @@ func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) {
1349917 }}
1350918 local2 := &recordingTransport{recvs: []recvItem{
1351919 {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,
1446922 }))},
1447- {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)},
1448923 }}
1449- remote3 := &recordingTransport{recvs: []recvItem{
1450- {f: welcome("remote-new", remoteNewLifecycle, 22)},
924+ remote2 := &recordingTransport{recvs: []recvItem{
925+ {f: welcome("created", remoteLifecycle)},
1451926 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1452927 }}
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}}
1456930 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+ }}
1465939
1466940 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",
1468942 })
1469- remoteHello := helloFromSend(t, remote2)
1470943 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)
1484955 }
1485956
1486957 func mustMarshalCommittedIdentity(identity protocol.CommittedRouteIdentity) []byte {
@@ -1507,6 +978,14 @@ func mustMarshalRoutePosition(position protocol.RoutePosition) []byte {
1507978 return payload
1508979 }
1509980
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+
1510989 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
1511990 var out bytes.Buffer
1512991 var restoreCount atomic.Int32
@@ -2217,451 +1696,6 @@ func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testin
22171696 require.Nil(t, reconnectHello.RemoteTarget)
22181697 }
22191698
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-
26651699 func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) {
26661700 term := newRunTerminal()
26671701 defer term.in.unblock()
@@ -2673,3 +1707,7 @@ func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) {
26731707 require.Equal(t, int32(1), d.calls.Load())
26741708 require.Equal(t, int32(1), term.restoreCount.Load())
26751709 }
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
2626 // text may legitimately contain it) — is forwarded to next unchanged, so it
2727 // still gets the coalescer's marker-splitting treatment.
2828 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
2937 coalescer *pasteCoalescer
3038 reader ports.ClipboardReader
3139 log *slog.Logger
@@ -97,7 +105,11 @@ func pasteMarkerBoundary(data []byte) int {
97105 }
98106
99107 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)
101113 if err != nil {
102114 if !errors.Is(err, ports.ErrNoClipboardImage) {
103115 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
197197 func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil }
198198 func (t *inventoryAttemptTransport) Close() error { return nil }
199199
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+
200216 func (t *inventoryAttemptTransport) publicationCount() int {
201217 t.mu.Lock()
202218 defer t.mu.Unlock()
@@ -221,23 +237,39 @@ func (t *inventoryAttemptTransport) hello() protocol.Hello {
221237 // inventoryTestControlConn answers one snapshot or resolve query from canned
222238 // groups without Hello, session creation, or attachment.
223239 type inventoryTestControlConn struct {
224- request protocol.NavigationInventoryRequest
240+ inventoryRequest protocol.NavigationInventoryRequest
241+ pickerRequest protocol.PickerControlRequest
242+ onPicker func()
225243 }
226244
227245 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:
230255 return io.ErrUnexpectedEOF
231256 }
232- c.request = request
233257 return nil
234258 }
235259
236260 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 {
238270 case protocol.NavigationInventorySnapshot:
239271 return protocol.NavigationInventoryResponse{
240- RequestID: c.request.RequestID, Operation: protocol.NavigationInventorySnapshot,
272+ RequestID: request.RequestID, Operation: protocol.NavigationInventorySnapshot,
241273 Status: protocol.NavigationInventoryOK,
242274 Groups: []protocol.NavigationInventorySourceGroup{{
243275 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
@@ -249,7 +281,7 @@ func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, erro
249281 case protocol.NavigationInventoryResolve:
250282 lifecycle := domain.SessionLifecycleID{7}
251283 return protocol.NavigationInventoryResponse{
252- RequestID: c.request.RequestID, Operation: protocol.NavigationInventoryResolve,
284+ RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve,
253285 Status: protocol.NavigationInventoryOK,
254286 Resolved: &protocol.AttachTarget{
255287 Session: "alpha", Intent: protocol.IntentAttach,
@@ -269,15 +301,28 @@ func (c *inventoryTestControlConn) LinkEvents() <-chan ports.LinkEvent { return
269301 func (c *inventoryTestControlConn) Close() error { return nil }
270302
271303 type inventoryTestDialer struct {
272- mu sync.Mutex
273- dials int
304+ mu sync.Mutex
305+ dials int
306+ pickerRequests int
274307 }
275308
276309 func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) {
277310 d.mu.Lock()
278311 d.dials++
279312 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
281326 }
282327
283328 func (d *inventoryTestDialer) count() int {
@@ -291,7 +336,7 @@ func runInventoryAttempt(t *testing.T, clock *inventoryTestClock, transport *inv
291336 input := newPaletteAttachReader(nil)
292337 t.Cleanup(input.close)
293338 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()}
295340 attempt := &attachAttempt{
296341 runner: runner, dialer: dialer, transport: transport,
297342 request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"},
@@ -313,6 +358,106 @@ func msForInventoryTest() *milestones { return &milestones{} }
313358 // TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery:
314359 // a late outcome from an older query must not overwrite the newer one,
315360 // 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+
316461 func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) {
317462 box := newInventoryPollBox()
318463 box.offer(inventoryPollOutcome{interaction: 2, query: 2})
@@ -436,3 +581,46 @@ func TestInventoryAttemptIgnoresDemandsWhenLocal(t *testing.T) {
436581 require.Zero(t, dialer.count())
437582 require.Zero(t, transport.publicationCount())
438583 }
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
7676 }).Twice()
7777 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
7878 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})
8280 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"}))
8381 for _, tr := range []*recordingTransport{resume, restored} {
8482 hello := helloFromSend(t, tr)
M internal/usecase/client/navigation_inventory.go +7 -1
@@ -164,7 +164,13 @@ func (r *inventoryRelay) preparePublication(groups []protocol.NavigationInventor
164164 if r == nil {
165165 return nil, 0, false
166166 }
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)
168174 if inventoryGroupsEqual(canonical, r.published) {
169175 return nil, 0, false
170176 }
M internal/usecase/client/navigation_inventory_test.go +18 -0
@@ -7,6 +7,7 @@ import (
77
88 "github.com/bnema/vev/internal/ports"
99 "github.com/bnema/vev/internal/protocol"
10+ "github.com/stretchr/testify/require"
1011 )
1112
1213 // manualClock is a fake ports.Clock with explicit time control: no sleeps.
@@ -178,6 +179,23 @@ func TestInventoryRelaySnapshotAdmitsAndSuppressesUnchanged(t *testing.T) {
178179 }
179180 }
180181
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+
181199 func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) {
182200 clock := &manualClock{now: time.Unix(1_000, 0)}
183201 groups := inventoryTestGroups()
M internal/usecase/client/navigation_test.go +3 -12
@@ -27,24 +27,15 @@ func TestSyncReconnectRemoteKeepsRequestAndToastClassificationTogether(t *testin
2727 func TestValidateAttachRequestNavigationTable(t *testing.T) {
2828 var lifecycle domain.SessionLifecycleID
2929 lifecycle[0] = 1
30- remoteRoute := &domain.RemoteSessionTarget{
31- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
32- SessionName: "work", LiveTabID: "tab-1",
33- }
3430 tests := []struct {
3531 name string
3632 request AttachRequest
3733 valid bool
3834 }{
3935 {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},
4839 }
4940
5041 for _, tt := range tests {
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -129,7 +129,7 @@ func newAttachPaletteTransport() *attachPaletteTransport {
129129 return &attachPaletteTransport{
130130 themeCh: make(chan protocol.Theme, 16),
131131 inputCh: make(chan protocol.Input, 16),
132- detached: make(chan wire.Frame, 1),
132+ detached: make(chan wire.Frame, 16),
133133 }
134134 }
135135 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) {
1818 {name: "missing session name", request: AttachRequest{Intent: protocol.IntentAttach}},
1919 {name: "unsafe session name", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "bad name"}},
2020 {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}},
2222 }
2323 for _, tt := range tests {
2424 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 {
569569 }
570570 }
571571
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+
572623 func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot {
573624 l.mu.RLock()
574625 defer l.mu.RUnlock()
@@ -622,7 +673,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
622673 request.RemoteTarget = nil
623674 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
624675 request.NavigationCapabilities = 0
625- request.StartupOverlay = protocol.StartupOverlayNone
626676 }
627677 request.EnvironmentPolicy = environmentPolicy
628678 request.ExactTarget = target.ExactTarget
@@ -640,7 +690,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
640690 request.RemoteTarget = nil
641691 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
642692 request.NavigationCapabilities = 0
643- request.StartupOverlay = protocol.StartupOverlayNone
644693 }
645694 request.EnvironmentPolicy = environmentPolicy
646695 return request
M internal/usecase/client/routes_test.go +57 -5
@@ -218,8 +218,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
218218 active := routeTestCandidate(2, protocol.RouteOriginLocal)
219219 active.originKey = "local"
220220 active.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
221- active.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker
222- active.request.StartupOverlay = protocol.StartupOverlaySessionPicker
221+ active.request.NavigationCapabilities = protocol.NavigationCapabilityInventory
223222
224223 for _, tt := range []struct {
225224 name string
@@ -235,8 +234,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
235234 stored := routeTestCandidate(1, protocol.RouteOriginLocal)
236235 stored.originKey = "local"
237236 stored.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
238- stored.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker
239- stored.request.StartupOverlay = protocol.StartupOverlaySessionPicker
237+ stored.request.NavigationCapabilities = protocol.NavigationCapabilityInventory
240238 _, err := ledger.commit(stored)
241239 require.NoError(t, err)
242240 }
@@ -248,7 +246,6 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
248246
249247 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
250248 require.Zero(t, request.NavigationCapabilities)
251- require.Equal(t, protocol.StartupOverlayNone, request.StartupOverlay)
252249 require.NoError(t, validateAttachRequest(request))
253250 })
254251 }
@@ -287,6 +284,61 @@ func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
287284 require.Nil(t, request.RemoteTarget)
288285 }
289286
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+
290342 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
291343 ledger := newRouteLedger()
292344 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) {
132132 case protocol.SamePeerSwitchRequest:
133133 p, e := wire.MarshalSamePeerSwitchRequest(x)
134134 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
137135 case protocol.RouteNavigationFailure:
138136 p, e := wire.MarshalRouteNavigationFailure(x)
139137 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e
@@ -144,6 +142,10 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
144142 return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil
145143 case protocol.NavigationInventoryFailure:
146144 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
147149 default:
148150 return wire.Frame{}, errors.New("test client: unsupported message")
149151 }
@@ -166,10 +168,6 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
166168 return wire.UnmarshalPong(f.Payload)
167169 case wire.MsgAttachTarget:
168170 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)
173171 case wire.MsgNavigateRecentRoute:
174172 return wire.UnmarshalRouteNavigationAction(f.Payload)
175173 case wire.MsgRouteCreateSession:
@@ -184,6 +182,16 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
184182 return wire.UnmarshalRoutePosition(f.Payload)
185183 case wire.MsgRouteNavigationFailure:
186184 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)
187195 default:
188196 return nil, errors.New("test client: unsupported server frame")
189197 }
M internal/usecase/client/typed_connection_test.go +30 -6
@@ -128,8 +128,6 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) {
128128 case protocol.SamePeerSwitchRequest:
129129 p, e := wire.MarshalSamePeerSwitchRequest(m)
130130 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
133131 case protocol.RecentRouteSnapshot:
134132 p, e := wire.MarshalRecentRouteSnapshot(m)
135133 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: p}, e
@@ -154,6 +152,24 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) {
154152 return wire.Frame{}, errors.New("test client connection: invalid inventory failure")
155153 }
156154 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
157173 default:
158174 return wire.Frame{}, errors.New("test client connection: unsupported client message")
159175 }
@@ -175,8 +191,6 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
175191 return wire.UnmarshalSessions(frame.Payload)
176192 case wire.MsgCommandResult:
177193 return wire.UnmarshalCommandResult(frame.Payload)
178- case wire.MsgNavigationAction:
179- return wire.UnmarshalNavigationDirective(frame.Payload)
180194 case wire.MsgAttachTarget:
181195 return wire.UnmarshalAttachTarget(frame.Payload)
182196 case wire.MsgRemotePreviewResponse:
@@ -193,14 +207,24 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
193207 return wire.UnmarshalRoutePosition(frame.Payload)
194208 case wire.MsgSamePeerSwitchFailure:
195209 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
196- case wire.MsgParkedRouteResponse:
197- return wire.UnmarshalParkedRouteResponse(frame.Payload)
198210 case wire.MsgNavigationInventoryResponse:
199211 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
200212 case wire.MsgNavigationInventoryDemand:
201213 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
202214 case wire.MsgNavigationInventorySelection:
203215 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)
204228 default:
205229 return nil, errors.New("test client connection: unsupported server frame")
206230 }
M internal/usecase/client/ui.go +74 -0
@@ -17,6 +17,80 @@ const uiActionHistory = 64
1717
1818 // UI owns attachment-local action and waiter bookkeeping. It never holds its
1919 // 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+
2094 type UI struct {
2195 state ports.UIState
2296 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
162162 defer timer.Stop()
163163 admissionCtx, cancelAdmission := context.WithCancel(ctx)
164164 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)}
166166 reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {
167167 u.mu.Lock()
168168 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)
6868 }
6969 }
7070
71-func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
71+func TestUIRunnerRemoteHandoffRequiresDestinationFull(t *testing.T) {
7272 ctx, cancel := context.WithCancel(context.Background())
7373 t.Cleanup(cancel)
7474 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
@@ -77,17 +77,17 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
7777 ui := NewUI(terminal, systemClock{})
7878 source := newUITestPeer(t, protocol.ConnectionCapabilities{})
7979 destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1})
80- picker := newUITestPeer(t, protocol.ConnectionCapabilities{})
8180 localDialer := portsmocks.NewMockClientDialer(t)
8281 localDialer.EXPECT().Dial(mock.Anything).Return(source.connection, nil).Once()
83- localDialer.EXPECT().Dial(mock.Anything).Return(picker.connection, nil).Once()
8482 remoteDialer := portsmocks.NewMockClientDialer(t)
8583 remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once()
8684 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)
9191 done := make(chan error, 1)
9292 go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }()
9393 t.Cleanup(func() {
@@ -118,7 +118,7 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
118118 results <- result
119119 }()
120120 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}
122122 awaitUIClientMessage[protocol.Hello](t, destination)
123123 remoteView := view
124124 remoteView.Route.Target = target
@@ -143,50 +143,4 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
143143 t.Fatal("destination full did not complete handoff")
144144 }
145145 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)
192146 }
M internal/usecase/daemon/activation_policy_test.go +10 -11
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/ports"
1111 "github.com/bnema/vev/internal/protocol/catalogue"
12- "github.com/bnema/vev/internal/usecase/picker"
1312 )
1413
1514 // TestActivationPolicyTable drives the D5 contract through the picker
@@ -26,7 +25,7 @@ func TestActivationPolicyTable(t *testing.T) {
2625 name string
2726 host ports.RemoteHostSnapshot
2827 session catalogue.RemoteCatalogSession
29- activation picker.RemoteActivation
28+ activation pickerRemoteActivation
3029 reason string
3130 rowPresent bool
3231 }{
@@ -34,21 +33,21 @@ func TestActivationPolicyTable(t *testing.T) {
3433 name: "live reachable attaches",
3534 host: reachableDirectoryHost("user@arch", now),
3635 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
37- activation: picker.RemoteAttach,
36+ activation: pickerRemoteAttach,
3837 rowPresent: true,
3938 },
4039 {
4140 name: "stopped reachable restarts",
4241 host: reachableDirectoryHost("user@arch", now),
4342 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs},
44- activation: picker.RemoteRestart,
43+ activation: pickerRemoteRestart,
4544 rowPresent: true,
4645 },
4746 {
4847 name: "broken is diagnostic only",
4948 host: reachableDirectoryHost("user@arch", now),
5049 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs},
51- activation: picker.RemoteUnavailable,
50+ activation: pickerRemoteUnavailable,
5251 reason: domain.RemoteReasonSessionBroken,
5352 rowPresent: true,
5453 },
@@ -59,7 +58,7 @@ func TestActivationPolicyTable(t *testing.T) {
5958 LastSuccess: now, InventoryKnown: true,
6059 },
6160 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
62- activation: picker.RemoteUnavailable,
61+ activation: pickerRemoteUnavailable,
6362 reason: domain.RemoteReasonVersionMismatch,
6463 rowPresent: true,
6564 },
@@ -70,7 +69,7 @@ func TestActivationPolicyTable(t *testing.T) {
7069 return host
7170 }(),
7271 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
73- activation: picker.RemoteAttach,
72+ activation: pickerRemoteAttach,
7473 rowPresent: true,
7574 },
7675 {
@@ -80,7 +79,7 @@ func TestActivationPolicyTable(t *testing.T) {
8079 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
8180 },
8281 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
83- activation: picker.RemoteAttach,
82+ activation: pickerRemoteAttach,
8483 reason: domain.RemoteReasonHostUnreachable,
8584 rowPresent: true,
8685 },
@@ -91,7 +90,7 @@ func TestActivationPolicyTable(t *testing.T) {
9190 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
9291 },
9392 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
94- activation: picker.RemoteAttach,
93+ activation: pickerRemoteAttach,
9594 reason: domain.RemoteReasonAuthFailure,
9695 rowPresent: true,
9796 },
@@ -102,7 +101,7 @@ func TestActivationPolicyTable(t *testing.T) {
102101 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
103102 },
104103 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
105- activation: picker.RemoteAttach,
104+ activation: pickerRemoteAttach,
106105 reason: domain.RemoteReasonMalformed,
107106 rowPresent: true,
108107 },
@@ -117,7 +116,7 @@ func TestActivationPolicyTable(t *testing.T) {
117116 seedRemoteDirectory(t, d, host)
118117
119118 views, _ := d.pickerViews(current, nil)
120- var found *picker.SessionView
119+ var found *pickerSessionView
121120 for i, view := range views {
122121 if view.RemoteHost == "user@arch" && view.Name == "work@arch" {
123122 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
5858 return captureAttachmentCapability(s, ac, tr)
5959 }
6060
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+
6188 func (t attachmentCapability) sameIdentity(other attachmentCapability) bool {
6289 return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac &&
6390 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 {
208208 case protocol.AttachTarget:
209209 navigation.CauseActionID = t.uiActionID
210210 message = navigation
211- case protocol.NavigationDirective:
212- navigation.CauseActionID = t.uiActionID
213- message = navigation
214211 case protocol.RouteNavigationAction:
215212 navigation.CauseActionID = t.uiActionID
216213 message = navigation
M internal/usecase/daemon/attachment_effect_gate_test.go +36 -4
@@ -784,17 +784,47 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
784784 require.Equal(t, 1, testAttachmentTabIndex(target))
785785 }
786786
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+
787815 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
788816 p, releasePTY := newBlockingPTY(t)
789817 defer releasePTY()
790818 d, sess, old, _ := newManualSessionWithPTYs(t, p)
791- d.enterPicker(sess, old)
819+ selection := openPickerForSession(t, d, sess, old, sess.id)
792820
793821 oldTransport := old.transport()
794822 rc := d.attachCoordinator(sess, nil, old, true)
795823 token := sess.captureAttachmentCapability(old, oldTransport)
796824 token.lease = rc.attachmentLease(old)
797825 old.installTestAttachmentCapability(token)
826+ actionEffect, admitted := old.beginAttachmentEffect(token)
827+ require.True(t, admitted)
798828
799829 admissionEnded := make(chan struct{})
800830 releaseAction := make(chan struct{})
@@ -809,7 +839,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
809839 }
810840 actionDone := make(chan struct{})
811841 go func() {
812- d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
842+ d.resolvePickerSelection(actionEffect, selection)
813843 close(actionDone)
814844 }()
815845 <-admissionEnded
@@ -834,13 +864,15 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
834864 p, release := newBlockingPTY(t)
835865 defer release()
836866 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
837- d.enterPicker(sess, ac)
867+ selection := openPickerForSession(t, d, sess, ac, sess.id)
838868 rc := d.attachCoordinator(sess, nil, ac, true)
839869 token := sess.captureAttachmentCapability(ac, ac.transport())
840870 token.lease = rc.attachmentLease(ac)
841871 ac.installTestAttachmentCapability(token)
872+ actionEffect, admitted := ac.beginAttachmentEffect(token)
873+ require.True(t, admitted)
842874
843- d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
875+ d.resolvePickerSelection(actionEffect, selection)
844876
845877 d.mu.Lock()
846878 _, registered := d.sessions[sess.id]
M internal/usecase/daemon/attachment_output.go +12 -0
@@ -578,6 +578,18 @@ func (s *attachmentOutput) atCapacity() bool {
578578 return s.outstandingAtomic.Load() >= maxOutstanding
579579 }
580580
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+
581593 func (s *attachmentOutput) outstanding() uint64 {
582594 if s == nil {
583595 return 0
M internal/usecase/daemon/attachment_transition.go +21 -0
@@ -153,14 +153,35 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
153153 if err != nil {
154154 return result, err
155155 }
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) {
156162 // A not-ready publication is still inside the Hello handshake. Welcome
157163 // carries the committed identity for those attachments and must remain the
158164 // first server frame. Ready transitions notify an already-running client.
159165 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
166+ if d.beforeAttachmentTransitionIdentityAdmission != nil {
167+ d.beforeAttachmentTransitionIdentityAdmission(result.published)
168+ }
160169 identityErr := errAttachmentTransition
161170 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
162171 identityErr = d.sendCommittedRouteIdentityForAttachment(effect)
163172 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()
164185 }
165186 if identityErr != nil {
166187 d.abortPublishedAttachmentTransition(result)
M internal/usecase/daemon/attention.go +29 -0
@@ -139,6 +139,26 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
139139 }
140140 }
141141
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+
142162 func (d *Daemon) repaintAllAttachedClients() {
143163 d.mu.Lock()
144164 sessions := sessionsSnapshot(d.sessions)
@@ -236,6 +256,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
236256 }
237257 case <-timer.C():
238258 d.advanceAttentionFrame()
259+ d.refreshClientPickers()
239260 d.repaintAllAttachedClients()
240261 }
241262
@@ -256,6 +277,14 @@ func (d *Daemon) anyAttention() bool {
256277 if sess.anyAttention() {
257278 return true
258279 }
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+ }
259288 }
260289 return false
261290 }
M internal/usecase/daemon/client.go +10 -24
@@ -43,7 +43,6 @@ type attachedClient struct {
4343 clientID [16]byte
4444 terminalCapabilities terminalcap.Capabilities
4545 navigationCapabilities protocol.NavigationCapabilities
46- startupOverlay protocol.StartupOverlay
4746 // lifecycle is the sole authority for attachment capability publication,
4847 // effect admission, transition freeze/drain, and connection generation.
4948 lifecycle attachmentLifecycle
@@ -92,10 +91,6 @@ type attachedClient struct {
9291 pendingRouteIdentity bool
9392 samePeerOfferMu sync.Mutex
9493 samePeerOffer *protocol.ExactSessionTarget
95- parkedRouteMu sync.Mutex
96- parkedRoute *parkedRouteLease
97- parkedRouteOutput atomic.Bool
98- parkedRouteFullPending atomic.Bool
9994 routeAttentionSubscription protocol.RouteAttentionSubscription
10095 routeSubscriptionTransport transportSnapshot
10196 routeObservationAfter map[protocol.RouteAttentionTarget]time.Time
@@ -392,15 +387,15 @@ func (ac *attachedClient) setRouteAttentionSubscription(subscription protocol.Ro
392387 ac.routeAttentionSubscription = subscription
393388 }
394389
395-func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.ExactSessionTarget, bool) {
390+func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.RouteAttentionTarget, bool) {
396391 ac.routeMu.RLock()
397392 defer ac.routeMu.RUnlock()
398393 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
401396 }
402397 }
403- return protocol.ExactSessionTarget{}, false
398+ return protocol.RouteAttentionTarget{}, false
404399 }
405400
406401 func (ac *attachedClient) ackOutputState(epoch, state uint64) {
@@ -508,9 +503,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
508503 if !ac.transportSnapshotCurrent(expected) {
509504 return expected.transport, errTransportReplaced
510505 }
511- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
512- return expected.transport, nil
513- }
514506 ac.output.lockView()
515507 defer ac.output.unlockView()
516508 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -525,9 +517,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
525517 if !ac.transportSnapshotCurrent(expected) {
526518 return errTransportReplaced
527519 }
528- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
529- return nil
530- }
531520 ac.output.lockView()
532521 defer ac.output.unlockView()
533522 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -630,7 +619,6 @@ type attachClientOptions struct {
630619 terminalCapabilities terminalcap.Capabilities
631620 capabilitiesSet bool
632621 navigationCapabilities protocol.NavigationCapabilities
633- startupOverlay protocol.StartupOverlay
634622 }
635623
636624 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
709697 clientID: opts.clientID,
710698 terminalCapabilities: opts.terminalCapabilities,
711699 navigationCapabilities: opts.navigationCapabilities,
712- startupOverlay: opts.startupOverlay,
713700 resumeCapable: opts.resumeCapable,
714701 resumeToken: resumeToken,
715702 }
@@ -802,8 +789,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
802789 // attachmentOutput publishes capacity atomically. Do not take
803790 // attached.sendMu here: a slow transport may be holding it for an
804791 // 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()
807793 },
808794 })
809795 installAttachmentRenderCoordinator(entry, rc)
@@ -994,11 +980,11 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom
994980 }
995981 ac.sendMu.Unlock()
996982
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)
1002988 }
1003989
1004990 sess := effect.sess
M internal/usecase/daemon/client_frame_routing.go +25 -2
@@ -137,10 +137,9 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
137137 d.resetOutput(effect)
138138 case protocol.SamePeerSwitchRequest:
139139 d.switchSamePeerForAttachment(effect, message)
140- case protocol.ParkedRouteRequest:
141- d.handleParkedRouteRequest(effect, message)
142140 case protocol.RecentRouteSnapshot:
143141 replayIdentity := effect.ac.setRouteSnapshot(message)
142+ d.pokeAttentionTicker()
144143 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot")
145144 if !replayIdentity {
146145 break
@@ -206,6 +205,30 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
206205 d.refreshPalette(effect.ac)
207206 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")
208207 }
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)
209232 case protocol.NavigationInventoryFailure:
210233 overlays := effect.ac.overlays
211234 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
7070 return
7171 }
7272 ac.clearSamePeerOffer()
73- ac.clearParkedRoute()
7473 if d.afterClientGoneDetach != nil {
7574 d.afterClientGoneDetach()
7675 }
@@ -99,7 +98,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
9998 // attachment parking marker left by a raced non-explicit teardown so
10099 // IntentResume waiters are not stranded on a never-published park.
101100 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
102- d.closePicker(ac)
101+ d.retirePicker(ac)
103102 d.closePalette(ac)
104103 if err := d.cleanupAttachmentOutput(ac); err != nil {
105104 d.log.Warn("output cleanup failed during detach", "err", err, "session", name)
@@ -180,7 +179,6 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentCapability, f
180179
181180 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.ServerConnection) {
182181 ac.clearSamePeerOffer()
183- ac.clearParkedRoute()
184182 name := sess.nameSnapshot()
185183 if rc := sess.renderCoordinator(); rc != nil {
186184 rc.noteDetach(ac)
@@ -192,7 +190,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
192190 return
193191 }
194192 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
195- d.closePicker(ac)
193+ d.retirePicker(ac)
196194 d.closePalette(ac)
197195 if err := d.cleanupAttachmentOutput(ac); err != nil {
198196 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
3838 if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {
3939 return
4040 }
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+ }
4147 path, err := d.writeClipboardImageForAttachment(effect, ip)
4248 if err != nil {
4349 d.log.Error("writing clipboard image failed", "err", err)
@@ -179,6 +185,11 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect
179185 if effect != nil && !effect.current() {
180186 return
181187 }
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+ }
182193 d.writeToPane(sess, p, data)
183194 }
184195
@@ -269,9 +280,6 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
269280 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
270281 return errAttachmentTransition
271282 }
272- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
273- return nil
274- }
275283 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
276284 return errAttachmentTransition
277285 }
M internal/usecase/daemon/copy_scroll_animation.go +1 -1
@@ -96,7 +96,7 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
9696 // Check presentation ownership before entering the copy lock. A
9797 // callback never locks another overlay or a session under copyMu.
9898 visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() &&
99- !rt.pickerActive() && !rt.noticesActive() && !rt.resizeModeActive()
99+ !rt.pickerClientActive() && !rt.noticesActive() && !rt.resizeModeActive()
100100 rt.copyMu.Lock()
101101 if rt.copyScroll.timer.timer != timer {
102102 rt.copyMu.Unlock()
M internal/usecase/daemon/daemon.go +13 -7
@@ -84,9 +84,6 @@ type Daemon struct {
8484 lastAllocatedCreatedAt int64
8585 mruSeq atomic.Uint64
8686 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
9087 // closing marks that shutdown has irreversibly begun. It is set under mu,
9188 // atomically with the event that makes shutdown inevitable (the registry
9289 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -170,6 +167,11 @@ type Daemon struct {
170167 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
171168 // attachment gates are frozen and drained, before architecture publication.
172169 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)
173175 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
174176 // seam used by transition validation tests.
175177 afterAttachmentTransitionCoordinatorsLocked func()
@@ -186,6 +188,10 @@ type Daemon struct {
186188 afterMoveTabSourceSnapshot func()
187189 beforeMovePaneCommit func()
188190 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)
189195 // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
190196 // attachment gate but before it checks session ownership.
191197 afterDetachAttachmentEffectsFrozen func()
@@ -1145,6 +1151,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
11451151 stopTransport()
11461152 finishHandshake()
11471153 d.handleNavigationInventory(tr, message)
1154+ case protocol.PickerControlRequest:
1155+ stopTransport()
1156+ finishHandshake()
1157+ d.handlePickerControl(tr, message)
11481158 case protocol.Kill:
11491159 stopTransport()
11501160 finishHandshake()
@@ -1414,9 +1424,6 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
14141424 failAttachment()
14151425 return
14161426 }
1417- if ac.startupOverlay == protocol.StartupOverlaySessionPicker {
1418- d.enterPicker(sess, ac)
1419- }
14201427 postWelcomeTicket.End()
14211428 stopHandshakeTransport()
14221429 finishHandshake()
@@ -1479,7 +1486,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
14791486 resumeCapable: true,
14801487 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
14811488 navigationCapabilities: h.NavigationCapabilities,
1482- startupOverlay: h.StartupOverlay,
14831489 terminalCapabilities: terminalCapabilities,
14841490 capabilitiesSet: true,
14851491 }
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
2929
3030 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3131 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+ }
3238 ac.mouseScan.Scan(data,
3339 func(ev mouse.Event) { d.handleMouse(ac, ev) },
3440 func(b []byte) {
@@ -43,6 +49,11 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4349 func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) {
4450 ac := effect.ac
4551 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+ }
4657 ac.mouseScan.Scan(data,
4758 func(ev mouse.Event) {
4859 if effect.current() {
@@ -72,7 +83,7 @@ func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
7283 frameEvent := ev
7384 ac.initOverlays()
7485 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() {
7687 return
7788 }
7889 sess := ac.currentSession()
M internal/usecase/daemon/input_test.go +2 -23
@@ -635,11 +635,10 @@ func TestOverlayInputPrecedence(t *testing.T) {
635635 check func(t *testing.T, ac *attachedClient, writes chan []byte)
636636 }{
637637 {
638- name: "prompt before palette picker copy and normal",
638+ name: "prompt before palette copy and normal",
639639 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {
640640 t.Helper()
641641 d.enterCopyMode(sess, ac)
642- d.enterPicker(sess, ac)
643642 d.enterPalette(sess, ac)
644643 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
645644 },
@@ -648,41 +647,21 @@ func TestOverlayInputPrecedence(t *testing.T) {
648647 t.Helper()
649648 require.Equal(t, "x", ac.overlays.prompt.Value())
650649 require.Equal(t, "", ac.overlays.palette.Query())
651- require.True(t, ac.overlays.pickerActive())
652650 require.True(t, ac.overlays.copyActive())
653651 requireNoPTYWrite(t, writes)
654652 },
655653 },
656654 {
657- name: "palette before picker copy and normal",
655+ name: "palette before copy and normal",
658656 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {
659657 t.Helper()
660658 d.enterCopyMode(sess, ac)
661- d.enterPicker(sess, ac)
662659 d.enterPalette(sess, ac)
663660 },
664661 input: []byte("x"),
665662 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
666663 t.Helper()
667664 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)
686665 require.True(t, ac.overlays.copyActive())
687666 requireNoPTYWrite(t, writes)
688667 },
M internal/usecase/daemon/move.go +6 -0
@@ -10,6 +10,9 @@ import (
1010 // immutable identity. Session names are advisory; the ID and incarnation are
1111 // the commit-time authority.
1212 type movePaneRequest struct {
13+ Follow bool
14+ BeforeFollow func() error
15+ AfterFollow func(*attachmentEffect)
1316 Attachment *attachedClient
1417 AttachmentCapability attachmentCapability
1518 Source moveSessionLocator
@@ -20,6 +23,9 @@ type movePaneRequest struct {
2023 }
2124
2225 type moveTabRequest struct {
26+ Follow bool
27+ BeforeFollow func() error
28+ AfterFollow func(*attachmentEffect)
2329 Attachment *attachedClient
2430 AttachmentCapability attachmentCapability
2531 Source moveSessionLocator
M internal/usecase/daemon/move_pane_admission.go +3 -0
@@ -26,6 +26,9 @@ type movePaneAdmission struct {
2626 func (p *movePaneTopology) transactionRequest() moveTransactionRequest {
2727 req := p.req
2828 return moveTransactionRequest{
29+ follow: req.Follow,
30+ beforeFollow: req.BeforeFollow,
31+ afterFollow: req.AfterFollow,
2932 operation: "pane",
3033 attachment: req.Attachment,
3134 attachmentCapability: req.AttachmentCapability,
M internal/usecase/daemon/move_picker.go +34 -126
@@ -3,9 +3,27 @@ package daemon
33
44 import (
55 "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/protocol"
67 "github.com/bnema/vev/internal/usecase/picker"
78 )
89
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+
927 func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1028 if d == nil || source.Session.ID == "" || source.TabID == "" {
1129 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
@@ -37,66 +55,22 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
3755 return nil
3856 }
3957
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 {
9364 destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
9465 switch intent {
95- case pickerMovePane:
66+ case protocol.PickerIntentMovePane:
9667 if target.TabID == "" {
9768 return errMovePaneInvalid
9869 }
9970 return d.movePane(movePaneRequest{
71+ Follow: true,
72+ BeforeFollow: beforeFollow,
73+ AfterFollow: afterFollow,
10074 Attachment: source.Attachment,
10175 AttachmentCapability: source.AttachmentCapability,
10276 Source: source.Session,
@@ -105,8 +79,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
10579 Destination: destination,
10680 DestinationTabID: target.TabID,
10781 })
108- case pickerMoveTab:
82+ case protocol.PickerIntentMoveTab:
10983 return d.moveTab(moveTabRequest{
84+ Follow: true,
85+ BeforeFollow: beforeFollow,
86+ AfterFollow: afterFollow,
11087 Attachment: source.Attachment,
11188 AttachmentCapability: source.AttachmentCapability,
11289 Source: source.Session,
@@ -118,7 +95,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
11895 }
11996 }
12097
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) {
12299 d.mu.Lock()
123100 sess := d.sessions[target.Session]
124101 if sess == nil {
@@ -131,7 +108,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
131108 if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132109 return nil, nil
133110 }
134- if intent == pickerMoveTab {
111+ if intent == protocol.PickerIntentMoveTab {
135112 if len(sess.tabs) == 0 {
136113 return nil, nil
137114 }
@@ -150,72 +127,3 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
150127 }
151128 return sess, sess.tabs[target.TabIndex]
152129 }
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 @@
11 package daemon
22
33 import (
4- "sync/atomic"
54 "testing"
65 "time"
76
@@ -9,7 +8,8 @@ import (
98
109 "github.com/bnema/vev/internal/domain"
1110 "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"
1313 )
1414
1515 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, *
1818 }
1919
2020 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) {
2136 t.Helper()
2237 sourcePTY, releaseSource := newBlockingPTY(t)
23- d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY)
38+ d, source, ac, sends := newManualSessionWithPTYsClock(t, clock, sourcePTY)
2439 source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
2540 d.mu.Lock()
2641 delete(d.sessions, domain.SessionID("manual"))
@@ -48,7 +63,69 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
4863 d.mu.Lock()
4964 d.sessions[destination.id] = destination
5065 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+ }
52129 }
53130
54131 func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
@@ -59,12 +136,13 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
59136 require.True(t, ac.overlays.paletteActive())
60137 d.handleInput(source, ac, []byte("MFP\r"))
61138 require.False(t, ac.overlays.paletteActive())
62- require.True(t, ac.overlays.pickerActive())
63139
64140 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
66143 ac.overlays.pickerMu.Unlock()
67- require.Equal(t, pickerMovePane, intent)
144+ require.True(t, open)
145+ require.Equal(t, protocol.PickerIntentMovePane, intent)
68146 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
69147 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
70148 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
@@ -79,12 +157,13 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
79157
80158 d.handleInput(source, ac, []byte("\x1b "))
81159 d.handleInput(source, ac, []byte("MAT\r"))
82- require.True(t, ac.overlays.pickerActive())
83160
84161 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
86164 ac.overlays.pickerMu.Unlock()
87- require.Equal(t, pickerMoveTab, intent)
165+ require.True(t, open)
166+ require.Equal(t, protocol.PickerIntentMoveTab, intent)
88167 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
89168 require.Equal(t, domain.PaneStableID(""), captured.PaneID)
90169 require.Same(t, ac, captured.Attachment)
@@ -100,116 +179,99 @@ func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
100179 d.handleInput(source, ac, []byte("\x1b "))
101180 d.handleInput(source, ac, []byte("MFP\r"))
102181
103- require.False(t, ac.overlays.pickerActive())
182+ require.False(t, ac.overlays.pickerClientActive())
104183 history := d.notices.history()
105184 require.NotEmpty(t, history)
106185 require.Equal(t, "No destination available.", history[0].Message)
107186 }
108187
109-func TestMovePickerEnterCommitsMovePane(t *testing.T) {
188+func TestMovePickerMoveCommitsPane(t *testing.T) {
110189 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
111190 defer releaseAll(releases)
112- source.mu.Lock()
113- clearAttachmentsForTestLocked(source)
114- source.mu.Unlock()
115191 sourceTab := source.tabs[0]
116192 moved := sourceTab.focusedPane()
117193
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)
123199
124- require.False(t, ac.overlays.pickerActive())
125200 require.Nil(t, source.tabs)
126201 require.Same(t, moved, destinationTab.panes[moved.id])
127202 require.Same(t, destination, moved.ownerSnapshot().session)
128203 }
129204
130-func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {
205+func TestMovePickerCommitMovePaneViaSharedAPI(t *testing.T) {
131206 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
132207 defer releaseAll(releases)
133- source.mu.Lock()
134- clearAttachmentsForTestLocked(source)
135- source.mu.Unlock()
136208 sourceTab := source.tabs[0]
137209 moved := sourceTab.focusedPane()
138210
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)
143213 ac.overlays.pickerMu.Lock()
144- target, ok := ac.overlays.picker.Selected()
214+ target, ok := ac.overlays.pickerKeys[selection.Key]
145215 ac.overlays.pickerMu.Unlock()
146216 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))
151218
152219 require.Nil(t, source.tabs)
153220 require.Same(t, moved, destinationTab.panes[moved.id])
154221 require.Same(t, destination, moved.ownerSnapshot().session)
155222 }
156223
157-func TestMovePickerEnterCommitsMoveTab(t *testing.T) {
224+func TestMovePickerMoveCommitsTab(t *testing.T) {
158225 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
159226 defer releaseAll(releases)
160- source.mu.Lock()
161- clearAttachmentsForTestLocked(source)
162- source.mu.Unlock()
163227 movedTab := source.tabs[0]
164228 movedTab.stableID = "moved-tab"
165229
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")
171239
172- require.False(t, ac.overlays.pickerActive())
173240 require.Nil(t, source.tabs)
174241 require.Len(t, destination.tabs, 2)
175242 require.Same(t, movedTab, destination.tabs[1])
176243 }
177244
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)
181247 defer releaseAll(releases)
182248 before := len(source.tabs)
183249
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())
195258 require.Len(t, source.tabs, before)
196259 }
197260
198-func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
261+func TestMovePickerStaleDestinationReportsNotice(t *testing.T) {
199262 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
200263 defer releaseAll(releases)
201264
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)
206268 d.mu.Lock()
207269 delete(d.sessions, destination.id)
208270 d.mu.Unlock()
209271
210- d.handlePickerInput(ac, []byte("\r"))
272+ effect.End()
273+ d.resolvePickerSelection(effect, selection)
211274
212- require.False(t, ac.overlays.pickerActive())
213275 history := d.notices.history()
214276 require.NotEmpty(t, history)
215277 require.Equal(t, "Destination is no longer available.", history[0].Message)
@@ -217,147 +279,433 @@ func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
217279 }
218280
219281 func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
220- d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
282+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
221283 defer releaseAll(releases)
222284 sourceTab := source.tabs[0]
223285
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)
228289 sourceTab.mu.Lock()
229290 delete(sourceTab.panes, sourceTab.tree.Focus)
230291 sourceTab.mu.Unlock()
231292
232- d.handlePickerInput(ac, []byte("\r"))
293+ effect.End()
294+ d.resolvePickerSelection(effect, selection)
233295
234- require.True(t, ac.overlays.pickerActive())
296+ require.True(t, ac.overlays.pickerClientActive())
235297 history := d.notices.history()
236298 require.NotEmpty(t, history)
237299 require.Equal(t, "Pane no longer exists.", history[0].Message)
238300 }
239301
240302 func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
241- d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
303+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
242304 defer releaseAll(releases)
243305
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)
248309 source.mu.Lock()
249310 source.tabs = nil
250311 source.mu.Unlock()
251312
252- d.handlePickerInput(ac, []byte("\r"))
313+ effect.End()
314+ d.resolvePickerSelection(effect, selection)
253315
254- require.True(t, ac.overlays.pickerActive())
316+ require.True(t, ac.overlays.pickerClientActive())
255317 history := d.notices.history()
256318 require.NotEmpty(t, history)
257319 require.Equal(t, "Tab no longer exists.", history[0].Message)
258320 }
259321
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)
262327 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)
270341
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+ })
277436 }
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{})
278454 go func() {
279- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
280- close(refreshed)
455+ d.resolvePickerSelection(effect, selection)
456+ close(done)
281457 }()
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+}
287467
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
295498 }
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")
299514 }
300515
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+}
311533
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
324567 }
325- oldDone := make(chan struct{})
568+ done := make(chan struct{})
326569 go func() {
327- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
328- close(oldDone)
570+ d.resolvePickerSelection(effect, selection)
571+ close(done)
329572 }()
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+
330577 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")
334581 }
335582
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+ }
345615 }
616+}
346617
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+ }
352631 }
353632
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)
356638 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")
357659
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+ }
363711 }
M internal/usecase/daemon/move_postcommit.go +41 -2
@@ -20,6 +20,8 @@ func firstMovePane(panes []*pane) *pane {
2020 }
2121
2222 type movePostcommitPlan struct {
23+ followResult attachmentTransitionResult
24+ afterFollow func(*attachmentEffect)
2325 source *session
2426 destination *session
2527 sourceName string
@@ -82,6 +84,45 @@ func (p movePostcommitPlan) execute(d *Daemon) {
8284 }
8385 p.unlockDispatch()
8486 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+ }
85126 attrs := []any{
86127 "operation", p.operation,
87128 "source_session", p.sourceName,
@@ -143,7 +184,6 @@ func (p movePostcommitPlan) execute(d *Daemon) {
143184 )
144185 }
145186 for _, attachment := range p.retiredAttachments {
146- d.unregisterPreview(attachment.ac)
147187 attachment.ac.clearCaptureFrames()
148188 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
149189 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) {
154194 if retirement.parked == nil || retirement.parked.ac == nil {
155195 continue
156196 }
157- d.unregisterPreview(retirement.parked.ac)
158197 retirement.parked.ac.clearCaptureFrames()
159198 }
160199 d.finishParkedAttachmentRetirements(p.retiredParked)
M internal/usecase/daemon/move_tab.go +3 -0
@@ -42,6 +42,9 @@ func (d *Daemon) moveTab(req moveTabRequest) error {
4242 func (p *moveTabTopology) transactionRequest() moveTransactionRequest {
4343 req := p.req
4444 return moveTransactionRequest{
45+ follow: req.Follow,
46+ beforeFollow: req.BeforeFollow,
47+ afterFollow: req.AfterFollow,
4548 operation: "tab",
4649 attachment: req.Attachment,
4750 attachmentCapability: req.AttachmentCapability,
M internal/usecase/daemon/move_transaction.go +59 -4
@@ -21,6 +21,9 @@ type moveTopology interface {
2121 }
2222
2323 type moveTransactionRequest struct {
24+ follow bool
25+ beforeFollow func() error
26+ afterFollow func(*attachmentEffect)
2427 operation string
2528 attachment *attachedClient
2629 attachmentCapability attachmentCapability
@@ -57,6 +60,8 @@ type moveTransaction struct {
5760 frozenEffects attachmentTransitionGuard
5861 effectsFrozen bool
5962
63+ followResult attachmentTransitionResult
64+
6065 publication moveTopologyPublication
6166 publishErr error
6267
@@ -121,6 +126,11 @@ func (d *Daemon) executeMove(topology moveTopology) (result error) {
121126 return err
122127 }
123128 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+ }
124134 if err := t.freezeSourceAttachments(); err != nil {
125135 return err
126136 }
@@ -210,11 +220,18 @@ func (t *moveTransaction) admit() error {
210220 // attachment lifecycle Module. Move uses nonblocking acquisition to preserve
211221 // its ordering with final teardown.
212222 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 {
214224 return nil
215225 }
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+ }
218235 if transport := t.sourceTransports[attachment]; transport.transport != nil {
219236 interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
220237 }
@@ -227,7 +244,7 @@ func (t *moveTransaction) freezeSourceAttachments() error {
227244 t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
228245 }
229246 },
230- }, t.sourceAttachments...)
247+ }, participants...)
231248 if !frozen.acquired || !frozen.drained {
232249 frozen.unfreeze()
233250 return errMovePaneInvalid
@@ -260,11 +277,40 @@ func (t *moveTransaction) publishLocked() error {
260277 return errMoveStaleTarget
261278 }
262279
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+ }
263299 publication, err := t.topology.publishLocked(d, t.source, t.destination)
264300 if err != nil {
265301 return err
266302 }
267303 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+ }
268314 if publication.sourceEmpty {
269315 d.unregisterSessionLocked(t.source)
270316 t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
@@ -288,6 +334,8 @@ func (t *moveTransaction) publishLocked() error {
288334 func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
289335 publication := t.publication
290336 return movePostcommitPlan{
337+ followResult: t.followResult,
338+ afterFollow: t.request.afterFollow,
291339 source: t.source,
292340 destination: t.destination,
293341 sourceName: t.sourceName,
@@ -314,3 +362,10 @@ func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *mov
314362 destinationMetadataValid: t.destMetadataOK,
315363 }
316364 }
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) {
2323 {
2424 name: "picker selection", releasedAction: "detach",
2525 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))
2727 return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
2828 },
2929 },
M internal/usecase/daemon/overlay_runtime.go +47 -44
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "context"
54 "sync"
65 "time"
76
@@ -19,28 +18,45 @@ type overlayRuntime struct {
1918 ac *attachedClient
2019
2120 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
4460
4561 paletteMu sync.Mutex
4662 palette *palette.Model
@@ -136,7 +152,7 @@ func (rt *overlayRuntime) Active() bool {
136152 if rt == nil || rt.ac == nil {
137153 return false
138154 }
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()
140156 }
141157
142158 func (rt *overlayRuntime) promptActive() bool {
@@ -157,13 +173,18 @@ func (rt *overlayRuntime) paletteActive() bool {
157173 return rt.palette != nil
158174 }
159175
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 {
161182 if rt == nil {
162183 return false
163184 }
164185 rt.pickerMu.Lock()
165186 defer rt.pickerMu.Unlock()
166- return rt.picker != nil
187+ return rt.pickerOpen
167188 }
168189
169190 func (rt *overlayRuntime) noticesActive() bool {
@@ -282,10 +303,6 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach
282303 d.handlePaletteInput(ac, data, effect)
283304 return true
284305 }
285- if rt.pickerActive() {
286- d.handlePickerInput(ac, data, effect)
287- return true
288- }
289306 if rt.noticesActive() {
290307 d.handleNoticesInput(ac, data)
291308 return true
@@ -311,12 +328,6 @@ type overlayRenderSnapshot struct {
311328 statusFeedback string
312329 resizeActive bool
313330
314- pickerActive bool
315- pickerModel *picker.Model
316- pickerTitle string
317- previewTab *tab
318- remotePreview picker.Preview
319-
320331 noticesOverlayActive bool
321332 noticesOverlayModel *notices.Model
322333
@@ -383,14 +394,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
383394 snap.resizeActive = rt.resizeActive
384395 rt.resizeMu.Unlock()
385396
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-
394397 rt.paletteMu.Lock()
395398 snap.paletteModel = rt.palette
396399 snap.paletteActive = snap.paletteModel != nil
M internal/usecase/daemon/overlay_runtime_test.go +2 -22
@@ -31,11 +31,10 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
3131 check func(t *testing.T, ac *attachedClient)
3232 }{
3333 {
34- name: "prompt before palette picker copy",
34+ name: "prompt before palette copy",
3535 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
3636 t.Helper()
3737 d.enterCopyMode(sess, ac)
38- d.enterPicker(sess, ac)
3938 d.enterPalette(sess, ac)
4039 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
4140 },
@@ -44,39 +43,20 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
4443 t.Helper()
4544 require.Equal(t, "x", ac.overlays.prompt.Value())
4645 require.Equal(t, "", ac.overlays.palette.Query())
47- require.True(t, ac.overlays.pickerActive())
4846 require.True(t, ac.overlays.copyActive())
4947 },
5048 },
5149 {
52- name: "palette before picker copy",
50+ name: "palette before copy",
5351 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
5452 t.Helper()
5553 d.enterCopyMode(sess, ac)
56- d.enterPicker(sess, ac)
5754 d.enterPalette(sess, ac)
5855 },
5956 input: []byte("x"),
6057 check: func(t *testing.T, ac *attachedClient) {
6158 t.Helper()
6259 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)
8060 require.True(t, ac.overlays.copyActive())
8161 },
8262 },
M internal/usecase/daemon/palette.go +64 -6
@@ -737,6 +737,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
737737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
738738 return
739739 }
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+ }
740746 sess.dispatchMu.Lock()
741747 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
742748 sess.dispatchMu.Unlock()
@@ -1046,7 +1052,7 @@ func (e paletteExec) OpenMovePanePicker() error {
10461052 if target.tab == nil || target.pane == nil {
10471053 return errMovePaneInvalid
10481054 }
1049- return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
1055+ return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{
10501056 Session: sessionMoveLocator(e.sess),
10511057 TabID: domain.TabStableID(target.tab.stableID),
10521058 PaneID: domain.PaneStableID(target.pane.stableID),
@@ -1059,13 +1065,33 @@ func (e paletteExec) OpenMoveTabPicker() error {
10591065 if target.tab == nil {
10601066 return errMovePaneInvalid
10611067 }
1062- return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
1068+ return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{
10631069 Session: sessionMoveLocator(e.sess),
10641070 TabID: domain.TabStableID(target.tab.stableID),
10651071 Attachment: e.ac,
10661072 })
10671073 }
10681074
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+
10691095 func (e paletteExec) focus(direction layout.Direction) error {
10701096 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
10711097 }
@@ -1173,11 +1199,43 @@ func (e paletteExec) RenameTabTo(name string) error {
11731199 }
11741200
11751201 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
11781204 }
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)
11811239 }
11821240
11831241 func (e paletteExec) OpenNotifications() error {
M internal/usecase/daemon/palette_test.go +19 -7
@@ -771,20 +771,26 @@ func TestRemoteDirectoryTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
771771 d := newRemotePickerDaemon()
772772 seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work")))
773773 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)
774777
775778 // Opening overlays builds from the latest snapshot without requesting
776779 // 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))
778781 d.enterPalette(sess, ac)
779- require.True(t, ac.overlays.pickerActive())
782+ require.True(t, ac.overlays.pickerClientActive())
780783 require.True(t, ac.overlays.paletteActive())
781784
782785 d.closePalette(ac)
783786 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")
785788
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())
788794 }
789795
790796 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
@@ -1573,8 +1579,14 @@ func TestPaletteExecMethods(t *testing.T) {
15731579 d.closePrompt(ac)
15741580 require.True(t, sess.ephemeral)
15751581 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()
15781590 require.NoError(t, exec.EnterVisualMode())
15791591 require.True(t, ac.overlays.copyActive())
15801592 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) {
4848 }
4949
5050 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})
5252
5353 publishBenchmarkSnapshot(t, fixture, 1)
5454 fixture.snapshots.reset()
@@ -64,7 +64,7 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *
6464 }
6565
6666 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})
6868
6969 // This contract concerns capture and publication bytes, not queue latency.
7070 // Publish synchronously so the 10k-row setup cannot race the test deadline.
@@ -120,8 +120,8 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
120120 mutate func(*performanceFixture, int)
121121 wantHistory bool
122122 }{
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},
125125 } {
126126 t.Run(tt.name, func(t *testing.T) {
127127 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) {
142142 }
143143
144144 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})
146149 fixture.activePane.mu.Lock()
147150 rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
148151 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")
150153 require.Positive(t, bytes)
151154 require.LessOrEqual(t, bytes, budget)
152155 }
@@ -296,25 +299,25 @@ func TestTransactionalResizeMetrics(t *testing.T) {
296299 }
297300
298301 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})
300303
301304 require.Len(t, fixture.sess.tabs, 4)
302305 for _, tb := range fixture.sess.tabs {
303306 require.Len(t, tb.panes, 4)
304307 for _, p := range tb.panes {
305- require.Equal(t, 10_000, p.history.Len())
308+ require.Equal(t, perfHistoryRows, p.history.Len())
306309 }
307310 }
308311 }
309312
310313 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})
312315
313316 first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
314317 require.True(t, ok)
315318 require.Len(t, first.tabs, 1)
316319 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())
318321 require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
319322
320323 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
@@ -434,7 +437,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
434437 for _, topology := range daemonHistoryTopologies {
435438 b.Run(topology.name, func(b *testing.B) {
436439 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,
438441 })
439442 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
440443 b.Fatal("invalid daemon history fixture")
@@ -483,7 +486,7 @@ func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run fu
483486 func benchmarkDaemonSnapshotCapture(b *testing.B) {
484487 for _, topology := range daemonSnapshotTopologies {
485488 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})
487490 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
488491 b.Fatal("invalid daemon snapshot capture fixture")
489492 }
@@ -525,14 +528,14 @@ type snapshotBenchmarkScenario struct {
525528 }
526529
527530 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},
531534 // Leave one row below the retention cap so the mutation changes only the
532535 // mutable tail and cannot evict a sealed chunk.
533536 {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},
536539 }
537540
538541 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
@@ -685,7 +688,7 @@ func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnap
685688 }
686689
687690 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})
689692 if !fixture.hasHistoryTopology(1, 1, 10_000) {
690693 b.Fatal("invalid daemon snapshot restore fixture")
691694 }
@@ -837,7 +840,7 @@ func TestLivePaintAllocationBudget(t *testing.T) {
837840 {name: "1tab-4panes", panes: 4},
838841 } {
839842 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})
841844 run := func() {
842845 fixture.paintLive()
843846 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
@@ -866,7 +869,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
866869 {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106},
867870 } {
868871 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})
870873 run := func() {
871874 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
872875 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
@@ -1244,7 +1247,7 @@ func (f *performanceFixture) searchMatches() int {
12441247 func (f *performanceFixture) copyModeActive() bool {
12451248 f.ac.overlays.copyMu.Lock()
12461249 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
12481251 }
12491252
12501253 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
33
44 import (
55 "context"
6- "errors"
76 "sort"
87 "time"
98
@@ -11,85 +10,29 @@ import (
1110 "github.com/bnema/vev/internal/domain"
1211 "github.com/bnema/vev/internal/protocol"
1312 "github.com/bnema/vev/internal/protocol/catalogue"
14- "github.com/bnema/vev/internal/usecase/keys"
1513 "github.com/bnema/vev/internal/usecase/layout"
1614 "github.com/bnema/vev/internal/usecase/picker"
1715 "github.com/bnema/vev/internal/usecase/ui"
1816 )
1917
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
2122
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
2524
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
8733 }
8834
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) {
9336 if cur != nil && ac != nil {
9437 cur.repairAttachmentView(ac)
9538 }
@@ -106,17 +49,17 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
10649 // renameSession mid-sort. Remote-catalog locks are released before sorting
10750 // and picker-row construction.
10851 live := inv.live
109- var current picker.SourceFilter
52+ var current pickerSourceFilter
11053 for _, item := range live {
11154 snap := item.view
11255 if item.sess == cur {
11356 if ac != nil {
11457 view := ac.viewSnapshot()
11558 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}
11760 }
11861 } 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}
12063 }
12164 }
12265 }
@@ -135,13 +78,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
13578 }
13679 return stopped[i].name < stopped[j].name
13780 })
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- }
14581
14682 catalogRows := 0
14783 for _, host := range hosts {
@@ -150,16 +86,38 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
15086 catalogRows++
15187 }
15288 }
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)
155114 for i, item := range live {
156115 view := item.view.pickerView()
157- if grouped && i == 0 {
116+ if i == 0 {
158117 view.Section = "LOCAL"
159118 }
160119 views = append(views, view)
161120 }
162- publishedRemote := false
163121 for _, host := range hosts {
164122 publishedForHost := 0
165123 for _, session := range host.Sessions {
@@ -168,38 +126,29 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
168126 continue
169127 }
170128 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
176132 }
177133 views = append(views, view)
178134 publishedForHost++
179- publishedRemote = true
180135 }
181136 if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
182137 view := remotePickerHostView(host, now)
183- if grouped {
184- view.Section = "REMOTE " + host.Endpoint
185- }
138+ view.Section = "REMOTE " + host.Endpoint
186139 views = append(views, view)
187- publishedRemote = true
188140 }
189141 }
190142 // A nil directory means remote monitoring is not installed at all:
191143 // only an installed-but-unpublished monitor reads as "checking".
192- if monitored && !initialized {
144+ if checking {
193145 view := remotePickerCheckingView()
194- if grouped {
195- view.Section = "REMOTE"
196- }
146+ view.Section = "REMOTE"
197147 views = append(views, view)
198- publishedRemote = true
199148 }
200149 for i, s := range stopped {
201150 createdAt := s.createdAt
202- view := picker.SessionView{
151+ view := pickerSessionView{
203152 ID: domain.SessionID("stopped:" + s.name),
204153 Incarnation: s.incarnation,
205154 Name: s.name,
@@ -207,26 +156,12 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
207156 Stopped: true,
208157 ExpectedCreatedAt: &createdAt,
209158 }
210- if grouped && i == 0 && (len(live) == 0 || publishedRemote) {
159+ if i == 0 && len(live) == 0 && catalogRows == 0 && !checking {
211160 view.Section = "LOCAL"
212161 }
213162 views = append(views, view)
214163 }
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
230165 }
231166
232167 func attentionSuffix(label string) string {
@@ -273,391 +208,9 @@ func remotePickerReasonText(reason string) string {
273208 }
274209 }
275210
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-
657211 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
661214 if width <= 0 || height <= 0 {
662215 return 0, 0
663216 }
@@ -682,137 +235,6 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
682235 return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
683236 }
684237
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-
816238 // pickerRouteTargetsEqual compares the exact route identity of two picker
817239 // targets, ignoring presentation fields such as UnavailableReason that
818240 // render around the route on every observation.
@@ -853,19 +275,8 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
853275 // one active-session handoff. Its zero value keeps daemon-owned transitions on
854276 // their established path.
855277 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
869280 }
870281
871282 // switchActiveTargetForAttachment hands a frame-bound navigation request to the
@@ -932,13 +343,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
932343 }
933344 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
934345 }
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- }
942346 d.touchMRU(targetSess)
943347 d.deferAttachmentTransitionCleanups(transition)
944348 d.firstPaintForTransition(transition.published)
@@ -998,17 +402,14 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
998402 if !matches || !effect.current() {
999403 return errAttachmentTransition
1000404 }
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.
1006407 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
1007408 }
1008409
1009410 // 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
1012413 if exactTarget == nil {
1013414 return errAttachmentTransition
1014415 }
@@ -1041,9 +442,6 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
1041442 // handoff instead of losing their target-specific transition semantics.
1042443 // Keep the source link open until the client receives the ordered cleanup
1043444 // and handoff frames, then let the client's close drive ordinary parking.
1044- if guard.closePicker {
1045- d.closePickerForGuard(effect.ac, guard)
1046- }
1047445 effect.bindActionEnd(d, "detach")
1048446 effect.End()
1049447 return nil
@@ -1081,9 +479,6 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
1081479 if err := effect.sendControl(handoff); err != nil {
1082480 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
1083481 }
1084- if guard.closePicker {
1085- d.closePickerForGuard(effect.ac, guard)
1086- }
1087482 d.clientGoneForAttachment(effect, false)
1088483 return nil
1089484 }
@@ -1178,17 +573,6 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1178573 d.invalidateRender(from, ac, true, "picker.go")
1179574 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1180575 }
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- }
1192576 d.deferAttachmentTransitionCleanups(transition)
1193577 if targetSess == from {
1194578 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 &copyValue
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)
3838 func formatRecentRouteSnapshot(snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay {
3939 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
4040 }
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
33 import (
44 "testing"
55
6- "github.com/bnema/vev/internal/domain"
76 "github.com/bnema/vev/internal/protocol"
87 "github.com/bnema/vev/internal/protocol/wire"
98 "github.com/stretchr/testify/require"
@@ -26,41 +25,20 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
2625 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
2726 }
2827
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)
6442 }
6543
6644 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
103103 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
104104 }
105105
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-
129106 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error {
130107 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
131108 return errAttachmentTransition
M internal/usecase/daemon/remote_attach_test.go +2 -2
@@ -59,7 +59,7 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
5959
6060 require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{
6161 Name: "stopped", Incarnation: lifecycle,
62- }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select"))
62+ }, sessionHandoffGuard{allowSamePeer: true}, "picker-select"))
6363
6464 cleanup := receiveRemotePicker(t, sends, "graphics cleanup")
6565 require.Equal(t, wire.MsgOutput, cleanup.Type)
@@ -87,7 +87,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
8787 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
8888 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
8989 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"))
9191 frame := receiveRemotePicker(t, sends, "rich attach target")
9292 got, err := wire.UnmarshalAttachTarget(frame.Payload)
9393 require.NoError(t, err)
M internal/usecase/daemon/remote_directory_view.go +4 -2
@@ -76,13 +76,15 @@ func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) {
7676 if ac == nil || ac.overlays == nil {
7777 return
7878 }
79- pickerOpen := ac.overlays.pickerActive()
79+ pickerOpen := ac.overlays.pickerClientActive()
8080 paletteOpen := ac.overlays.paletteActive()
8181 if !pickerOpen && !paletteOpen {
8282 return
8383 }
84+ // The picker and the palette both read the directory: republish the
85+ // interaction snapshot with a new revision when the row set changed.
8486 if pickerOpen {
85- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
87+ d.refreshPickerSnapshot(ac)
8688 }
8789 if paletteOpen {
8890 d.refreshPalette(ac)
M internal/usecase/daemon/remote_picker.go +27 -24
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/ports"
1111 "github.com/bnema/vev/internal/protocol/catalogue"
12- "github.com/bnema/vev/internal/usecase/picker"
1312 )
1413
1514 // directoryInventoryStaleAge is the presentation freshness threshold: past
@@ -64,12 +63,12 @@ func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool
6463 return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))
6564 }
6665
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 {
6867 key, target := remoteCatalogSessionTarget(key, session)
6968 stopped := remoteSessionStateStopped(session.State)
7069 broken := session.State == catalogue.RemoteCatalogSessionBroken
7170 tabs := catalogue.CatalogTabs(session)
72- viewTabs := make([]picker.TabEntry, 0, len(tabs))
71+ viewTabs := make([]pickerTabEntry, 0, len(tabs))
7372 active := 0
7473 for i, tab := range tabs {
7574 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
@@ -79,7 +78,7 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
7978 if name == "" {
8079 name = fmt.Sprintf("%d", int(tab.Index)+1)
8180 }
82- viewTabs = append(viewTabs, picker.TabEntry{
81+ viewTabs = append(viewTabs, pickerTabEntry{
8382 TabID: domain.TabStableID(tab.ID),
8483 Name: name,
8584 RawName: tab.Name,
@@ -94,21 +93,25 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
9493 remoteTarget := &target
9594 reason := directorySessionReason(host, session, target)
9695 targetValid := remoteTarget.Validate() == nil
97- activation := picker.RemoteUnavailable
96+ activation := pickerRemoteUnavailable
9897 if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {
9998 // Known inventory is attemptable at any age: freshness is
10099 // presentation information, not attach authority. Exact session,
101100 // lifecycle and tab validation still applies at submit, and the
102101 // destination rejects precisely.
103102 if stopped {
104- activation = picker.RemoteRestart
103+ activation = pickerRemoteRestart
105104 } else {
106- activation = picker.RemoteAttach
105+ activation = pickerRemoteAttach
107106 }
108107 }
109108
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,
112115 Name: key.Display(),
113116 RemoteKey: &key,
114117 RemoteTarget: remoteTarget,
@@ -153,26 +156,26 @@ func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.Rem
153156 }
154157 }
155158
156-func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) picker.RemoteAvailability {
159+func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) pickerRemoteAvailability {
157160 switch host.Availability {
158161 case domain.RemoteAvailabilityReachable:
159162 if directoryInventoryStale(host, now) {
160- return picker.RemoteStale
163+ return pickerRemoteStale
161164 }
162- return picker.RemoteFresh
165+ return pickerRemoteFresh
163166 case domain.RemoteAvailabilityIncompatible:
164- return picker.RemoteVersionMismatch
167+ return pickerRemoteVersionMismatch
165168 case domain.RemoteAvailabilityUnknown:
166- return picker.RemoteCached
169+ return pickerRemoteCached
167170 default:
168171 if host.Checking {
169- return picker.RemoteCached
172+ return pickerRemoteCached
170173 }
171- return picker.RemoteStale
174+ return pickerRemoteStale
172175 }
173176 }
174177
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 {
176179 if host.Checking {
177180 return "checking remote…"
178181 }
@@ -202,7 +205,7 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo
202205 return "session broken"
203206 case reason == domain.RemoteReasonIdentityChanged:
204207 return "identity changed"
205- case activation == picker.RemoteRestart:
208+ case activation == pickerRemoteRestart:
206209 return "stopped — Enter to restart"
207210 case session.State == catalogue.RemoteCatalogSessionUp:
208211 return "up"
@@ -217,24 +220,24 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo
217220 // while the remote directory has never been initialized: it distinguishes
218221 // "still checking remotes" from "no remotes configured". Enter on the
219222 // 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{
222225 ID: domain.SessionID("remote:checking"),
223226 Name: "checking remotes…",
224- RemoteActivation: picker.RemoteUnavailable,
227+ RemoteActivation: pickerRemoteUnavailable,
225228 CannotAcceptMoves: true,
226229 }
227230 }
228231
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{
231234 ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),
232235 Name: host.Endpoint,
233236 RemoteHost: host.Endpoint,
234237 RemoteReason: directoryHostReason(host),
235238 RemoteAvailability: directoryPickerAvailability(host, now),
236239 RemoteDetail: directoryHostDetail(host),
237- RemoteActivation: picker.RemoteUnavailable,
240+ RemoteActivation: pickerRemoteUnavailable,
238241 CannotAcceptMoves: true,
239242 }
240243 }
M internal/usecase/daemon/remote_picker_test.go +227 -275
@@ -7,6 +7,7 @@ import (
77 "io"
88 "log/slog"
99 "math"
10+ "strings"
1011 "sync"
1112 "testing"
1213 "time"
@@ -107,7 +108,7 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
107108 require.True(t, view.Stopped)
108109 require.NotNil(t, view.RemoteTarget)
109110 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")
111112 require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
112113
113114 }
@@ -179,8 +180,8 @@ func TestRemoteCatalogTargetReadyIgnoresAge(t *testing.T) {
179180
180181 views, _ := d.pickerViews(nil, nil)
181182 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)
184185 }
185186
186187 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
@@ -241,8 +242,8 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
241242 require.NotNil(t, views[0].RemoteTarget)
242243 require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)
243244
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)
246247 require.True(t, ok)
247248 require.NotNil(t, target.RemoteTarget)
248249 require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)
@@ -258,7 +259,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
258259 tests := []struct {
259260 name string
260261 host ports.RemoteHostSnapshot
261- want picker.RemoteAvailability
262+ want pickerRemoteAvailability
262263 wantActivate bool
263264 }{
264265 {
@@ -267,7 +268,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
267268 Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown,
268269 Checking: true, InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
269270 },
270- want: picker.RemoteCached, wantActivate: true,
271+ want: pickerRemoteCached, wantActivate: true,
271272 },
272273 {
273274 name: "unreachable with inventory renders stale and stays attemptable",
@@ -276,7 +277,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
276277 LastSuccess: time.Unix(10, 0), InventoryKnown: true,
277278 Sessions: []catalogue.RemoteCatalogSession{session},
278279 },
279- want: picker.RemoteStale, wantActivate: true,
280+ want: pickerRemoteStale, wantActivate: true,
280281 },
281282 {
282283 name: "version mismatch gates activation",
@@ -284,7 +285,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
284285 Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible,
285286 InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
286287 },
287- want: picker.RemoteVersionMismatch, wantActivate: false,
288+ want: pickerRemoteVersionMismatch, wantActivate: false,
288289 },
289290 }
290291
@@ -298,9 +299,9 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
298299 require.Len(t, views, 1)
299300 require.Equal(t, test.want, views[0].RemoteAvailability)
300301 if test.wantActivate {
301- require.NotEqual(t, picker.RemoteUnavailable, views[0].RemoteActivation)
302+ require.NotEqual(t, pickerRemoteUnavailable, views[0].RemoteActivation)
302303 } else {
303- require.Equal(t, picker.RemoteUnavailable, views[0].RemoteActivation)
304+ require.Equal(t, pickerRemoteUnavailable, views[0].RemoteActivation)
304305 }
305306 })
306307 }
@@ -326,19 +327,19 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) {
326327 for _, test := range []struct {
327328 name string
328329 host ports.RemoteHostSnapshot
329- want picker.RemoteAvailability
330+ want pickerRemoteAvailability
330331 wantDetail string
331332 }{
332333 {
333334 name: "unreachable",
334335 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable},
335- want: picker.RemoteStale,
336+ want: pickerRemoteStale,
336337 wantDetail: "unreachable",
337338 },
338339 {
339340 name: "version mismatch",
340341 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible},
341- want: picker.RemoteVersionMismatch,
342+ want: pickerRemoteVersionMismatch,
342343 wantDetail: "version mismatch",
343344 },
344345 } {
@@ -352,7 +353,7 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) {
352353 require.Equal(t, "arch", views[0].Name)
353354 require.Equal(t, test.want, views[0].RemoteAvailability)
354355 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})
356357 _, selectable := model.Selected()
357358 require.False(t, selectable)
358359 })
@@ -387,7 +388,7 @@ func TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable(t *testing.T) {
387388 domain.RemoteSessionKey{Host: "arch", Name: "work"},
388389 session, test.host, now,
389390 )
390- require.Equal(t, picker.RemoteAttach, view.RemoteActivation)
391+ require.Equal(t, pickerRemoteAttach, view.RemoteActivation)
391392 require.NotNil(t, view.RemoteTarget)
392393 require.NoError(t, view.RemoteTarget.Validate())
393394 })
@@ -404,11 +405,13 @@ func TestRemotePickerOverlayCausesNoDirectoryIO(t *testing.T) {
404405 }
405406 d.remoteDirectory = directory
406407 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))
412415
413416 require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation")
414417 require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots")
@@ -480,6 +483,84 @@ func seedPickerDirectoryHost(t *testing.T, d *Daemon, endpoint string, fetchedAt
480483 seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...))
481484 }
482485
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+
483564 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
484565 lifecycle := remoteLifecycleForTest()
485566 remoteSession := catalogue.RemoteCatalogSession{
@@ -495,7 +576,7 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
495576 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
496577 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
497578 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"))
499580
500581 frame := receiveRemotePicker(t, sends, "attach target")
501582 require.Equal(t, wire.MsgAttachTarget, frame.Type)
@@ -519,43 +600,38 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
519600 {ID: "tab-b", Name: "beta", Index: 1},
520601 },
521602 }
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+ }
535623 }
536- model.Down()
624+ localAttachment.overlays.pickerMu.Unlock()
537625 }
626+ require.True(t, ok)
538627 require.NotNil(t, selected.RemoteTarget)
539628 require.True(t, selected.RemoteTarget.Stopped)
540629 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")
541632
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.
559635 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
560636 remote.mu.Lock()
561637 remote.inactive["work"] = inactiveSession{
@@ -566,8 +642,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
566642 remote.mu.Unlock()
567643 transport, _ := newCapturingTransport(t)
568644 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,
571647 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
572648 }, transport)
573649 require.NoError(t, err)
@@ -587,21 +663,16 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
587663 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
588664 host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)
589665 remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))
590- require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation)
666+ require.Equal(t, pickerRemoteRestart, remoteView.RemoteActivation)
591667
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"}}},
594670 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)
605676 require.NotNil(t, selected.RemoteTarget)
606677 require.True(t, selected.RemoteTarget.Stopped)
607678 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
@@ -612,7 +683,7 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
612683 token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
613684 effect, admitted := localAttachment.beginAttachmentEffect(token)
614685 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"))
616687
617688 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")
618689 handoff, err := wire.UnmarshalAttachTarget(frame.Payload)
@@ -636,41 +707,6 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
636707 require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
637708 }
638709
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-
674710 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
675711 lifecycle := remoteLifecycleForTest()
676712 remoteSession := catalogue.RemoteCatalogSession{
@@ -683,26 +719,23 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
683719 tr := portsmocks.NewMockServerConnection(t)
684720 tr.EXPECT().SendServer(mock.Anything).Return(cause)
685721 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)
690723
691724 gone := make(chan struct{})
692725 d.afterClientGoneDetach = func() { close(gone) }
693726 token := sess.captureAttachmentCapability(ac, tr)
694- effect, admitted := ac.beginAttachmentEffect(token)
727+ sendEffect, admitted := ac.beginAttachmentEffect(token)
695728 require.True(t, admitted)
696- defer effect.End()
729+ defer sendEffect.End()
697730 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
698731 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
699732 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")
701734 var userErr *domain.UserError
702735 require.ErrorAs(t, err, &userErr)
703736 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
704737 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")
706739 select {
707740 case <-gone:
708741 t.Fatal("failed control send reached clientGoneForAttachment")
@@ -711,6 +744,81 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
711744 require.Same(t, sess, ac.currentAttachmentSession())
712745 }
713746
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+
714822 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...appports.ServerConnection) (*session, *attachedClient, chan wire.Frame) {
715823 t.Helper()
716824 var tr appports.ServerConnection
@@ -747,24 +855,20 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
747855 d := newRemotePickerDaemon()
748856 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
749857 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)
755859
756860 test.teardown(d, sess, ac)
757861
758862 ac.overlays.pickerMu.Lock()
759- current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
863+ open, currentInteraction := ac.overlays.pickerOpen, ac.overlays.pickerInteraction
760864 ac.overlays.pickerMu.Unlock()
761865 if !test.resumeCapable {
762- require.Nil(t, current)
866+ require.False(t, open)
763867 return
764868 }
765869 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")
768872 d.mu.Lock()
769873 parked := d.parked[ac.resumeToken]
770874 d.mu.Unlock()
@@ -785,10 +889,10 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) {
785889 d.remoteDirectory = &stubRemoteDirectory{}
786890 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
787891
788- findChecking := func(views []picker.SessionView) bool {
892+ findChecking := func(views []pickerSessionView) bool {
789893 for _, view := range views {
790894 if view.ID == domain.SessionID("remote:checking") {
791- if view.RemoteActivation != picker.RemoteUnavailable || !view.CannotAcceptMoves {
895+ if view.RemoteActivation != pickerRemoteUnavailable || !view.CannotAcceptMoves {
792896 t.Fatal("checking row must be non-actionable")
793897 }
794898 return true
@@ -804,155 +908,3 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) {
804908 views, _ = d.pickerViews(sess, ac)
805909 require.False(t, findChecking(views), "initialized directory must retire the checking row")
806910 }
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 (
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
1515 "github.com/bnema/vev/internal/protocol"
16- "github.com/bnema/vev/internal/usecase/picker"
1716 )
1817
1918 type remotePreviewTestClock struct {
@@ -253,230 +252,6 @@ func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
253252 return immediateRemotePreviewTimer{ch: ch}
254253 }
255254
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-
480255 func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
481256 clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
482257 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 {
3030 attached := len(sess.attachments) != 0
3131 sess.mu.Unlock()
3232
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+ }
3740 }
3841 tb.mu.Lock()
3942 defer tb.mu.Unlock()
@@ -52,32 +55,6 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
5255 return false
5356 }
5457
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-
8158 // ptyReader is retained as a compatibility entry point for focused tests. The
8259 // production goroutine calls readPanePTY, whose closure owns only the pane.
8360 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
360337
361338 ac.sendMu.Lock()
362339 if paintEffect != nil {
363- if !paintEffect.current() || ac.parkedRouteOutput.Load() {
340+ if !paintEffect.current() {
364341 ac.sendMu.Unlock()
365342 return paintRejected
366343 }
@@ -370,14 +347,11 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
370347 entry.core().mu.Lock()
371348 _, owned := entry.core().attachments[ac]
372349 entry.core().mu.Unlock()
373- if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {
350+ if !owned || ac.currentAttachmentSession() != entry {
374351 ac.sendMu.Unlock()
375352 return paintRejected
376353 }
377354 }
378- if ac.parkedRouteFullPending.Load() {
379- reset = true
380- }
381355 // Capacity is checked before any destructive capture. Refresh the atomic
382356 // readiness snapshot while sendMu is held so direct test/setup mutations of
383357 // 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
416390 d.repaintAllAttachedClients()
417391 }
418392 }()
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- }
426393 if local {
427394 d.refreshSessionFocusedTitles(sess)
428395 }
@@ -464,14 +431,13 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
464431
465432 capturedOverlays := capturedOverlayRenderState{
466433 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
467- pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
434+ paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
468435 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
469436 }
470437 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
471438 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
472439 bars: bars,
473440 overlays: capturedOverlays,
474- preview: preview,
475441 floatingCfg: floatingCfg,
476442 styles: applied.Resolved.Styles,
477443 styleGeneration: applied.Generation,
@@ -487,12 +453,6 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
487453 if ac.renderStages.capture != nil {
488454 ac.renderStages.capture()
489455 }
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- }
496456 captureOverlayLayers(state, overlays, paletteCfg)
497457 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
498458 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
@@ -500,10 +460,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
500460 if ac.renderStages.compose != nil {
501461 ac.renderStages.compose()
502462 }
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)
507464 return paintEmitted
508465 }
509466
M internal/usecase/daemon/render_capture.go +13 -30
@@ -7,7 +7,6 @@ import (
77 "github.com/bnema/vev/internal/protocol"
88 scopy "github.com/bnema/vev/internal/usecase/copy"
99 "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/internal/usecase/picker"
1110 themeui "github.com/bnema/vev/internal/usecase/theme"
1211 "github.com/bnema/vev/internal/usecase/ui"
1312 )
@@ -65,7 +64,6 @@ type capturedRenderState struct {
6564 // that cannot emit graphics. Capture keeps the scene out of the output but
6665 // lets the daemon issue one bounded user warning after pane locks release.
6766 suppressedGraphics bool
68- preview picker.Preview
6967 cursor capturedCursorInputs
7068 tabGeneration uint64
7169 floatingGeneration uint64
@@ -125,15 +123,15 @@ type capturedFloatingRenderState struct {
125123 }
126124
127125 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
137135 }
138136
139137 type capturedModal struct {
@@ -145,7 +143,7 @@ type capturedModal struct {
145143 }
146144
147145 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
149147 }
150148
151149 type capturedCursorInputs struct {
@@ -269,7 +267,6 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
269267 type renderCaptureRequest struct {
270268 bars barState
271269 overlays capturedOverlayRenderState
272- preview picker.Preview
273270 floatingCfg domain.FloatingConfig
274271 styles themeui.Styles
275272 styleGeneration uint64
@@ -289,7 +286,6 @@ func captureLocalRenderState(
289286 ) (*capturedRenderState, bool) {
290287 bars := request.bars
291288 overlays := request.overlays
292- preview := request.preview
293289 floatingCfg := request.floatingCfg
294290 reset := request.reset
295291 lease := request.lease
@@ -335,14 +331,9 @@ func captureLocalRenderState(
335331 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
336332 bars.rankedRecent = scratch.ranked
337333 }
338- if len(preview.Rows) > 0 {
339- preview = clonePickerPreview(preview)
340- } else {
341- preview = picker.Preview{Width: preview.Width, Height: preview.Height}
342- }
343-
344334 tb.mu.Lock()
345335 defer tb.mu.Unlock()
336+
346337 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
347338 var focus layout.PaneID
348339 var focusedPaneID domain.PaneStableID
@@ -392,7 +383,7 @@ func captureLocalRenderState(
392383 uiFence: request.uiFence,
393384 focusedPaneID: focusedPaneID,
394385 styles: request.styles, styleGeneration: request.styleGeneration,
395- overlays: overlays, preview: preview,
386+ overlays: overlays,
396387 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
397388 floatingGeneration: tb.floating.generation,
398389 receipts: scratch.receipts[:0],
@@ -486,18 +477,10 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
486477
487478 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
488479 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
490481 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}
491482 }
492483
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-
501484 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
502485 // stack title bar. The caller holds tab.mu, and dst is attachment scratch.
503486 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
631631 c.mu.Unlock()
632632 }
633633
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+
634646 func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
635647 if ac == nil {
636648 return false
M internal/usecase/daemon/render_coordinator_test.go +16 -4
@@ -1153,7 +1153,12 @@ func TestProducerInvalidations(t *testing.T) {
11531153 name string
11541154 tabs int
11551155 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)
11571162 }{
11581163 {
11591164 file: "attention.go",
@@ -1216,10 +1221,12 @@ func TestProducerInvalidations(t *testing.T) {
12161221 },
12171222 },
12181223 {
1219- file: "picker.go",
1220- name: "picker entry",
1224+ file: "picker.go",
1225+ name: "picker entry",
1226+ interaction: true,
12211227 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))
12231230 },
12241231 },
12251232 {
@@ -1287,6 +1294,11 @@ func TestProducerInvalidations(t *testing.T) {
12871294 require.Equal(t, tc.producer, inv.producer)
12881295 }
12891296 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+ }
12901302 requireNoCoordinatorOutputFrame(t, sends)
12911303 })
12921304 }
M internal/usecase/daemon/render_damage_receipt_test.go +0 -5
@@ -8,7 +8,6 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 "github.com/bnema/vev/internal/protocol/wire"
1010 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/picker"
1211 "github.com/stretchr/testify/require"
1312 )
1413
@@ -58,7 +57,6 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
5857 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
5958 bars: barState{},
6059 overlays: capturedOverlayRenderState{},
61- preview: pickerPreviewEmpty(),
6260 floatingCfg: domain.FloatingConfig{},
6361 reset: false,
6462 lease: nil,
@@ -254,7 +252,6 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
254252 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
255253 bars: barState{},
256254 overlays: capturedOverlayRenderState{},
257- preview: pickerPreviewEmpty(),
258255 floatingCfg: domain.FloatingConfig{},
259256 reset: false,
260257 lease: nil,
@@ -262,5 +259,3 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
262259 require.True(t, ok)
263260 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
264261 }
265-
266-func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
M internal/usecase/daemon/render_pipeline.go +4 -20
@@ -11,7 +11,6 @@ import (
1111 "github.com/bnema/vev/internal/usecase/layout"
1212 "github.com/bnema/vev/internal/usecase/notices"
1313 "github.com/bnema/vev/internal/usecase/palette"
14- "github.com/bnema/vev/internal/usecase/picker"
1514 "github.com/bnema/vev/internal/usecase/prompt"
1615 themeui "github.com/bnema/vev/internal/usecase/theme"
1716 "github.com/bnema/vev/internal/usecase/ui"
@@ -175,7 +174,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
175174 damage = floatingDamage
176175 }
177176 copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
178- !state.overlays.copySearchActive && !state.overlays.pickerActive &&
177+ !state.overlays.copySearchActive &&
179178 !state.overlays.paletteActive && !state.overlays.promptActive &&
180179 !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
181180 var copyViewport copyViewportState
@@ -269,7 +268,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
269268 return
270269 }
271270 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
273272 o.noticesOverlayActive = snap.noticesOverlayActive
274273 o.copyMode = snap.copyMode
275274 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
@@ -288,21 +287,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
288287 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
289288 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
290289 }
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- }
306290 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
307291 presentation := noticesModal.Resolve(size)
308292 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
363347 func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
364348 o := state.overlays
365349 // 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} {
368352 if !modal.active {
369353 continue
370354 }
M internal/usecase/daemon/render_pipeline_test.go +1 -8
@@ -21,7 +21,6 @@ import (
2121 "github.com/bnema/vev/internal/usecase/layout"
2222 "github.com/bnema/vev/internal/usecase/notices"
2323 "github.com/bnema/vev/internal/usecase/palette"
24- "github.com/bnema/vev/internal/usecase/picker"
2524 promptui "github.com/bnema/vev/internal/usecase/prompt"
2625 themeui "github.com/bnema/vev/internal/usecase/theme"
2726 "github.com/bnema/vev/internal/usecase/ui"
@@ -592,8 +591,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
592591 higher string
593592 }{
594593 {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"},
597595 {name: "prompt above palette", lower: "palette", higher: "prompt"},
598596 } {
599597 t.Run(tt.name, func(t *testing.T) {
@@ -604,8 +602,6 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
604602 state.overlays.copySearch = modal
605603 case "notices":
606604 state.overlays.noticesOverlay = modal
607- case "picker":
608- state.overlays.picker = modal
609605 case "palette":
610606 state.overlays.palette = modal
611607 case "prompt":
@@ -634,8 +630,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
634630 snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
635631 snap := &overlayRenderSnapshot{
636632 copySearchModel: visualsearch.New(snapshot),
637- pickerActive: true,
638- pickerModel: picker.New(nil, picker.SelectionConfig{}),
639633 noticesOverlayActive: true,
640634 noticesOverlayModel: notices.New(nil, time.Time{}),
641635 paletteActive: true,
@@ -648,7 +642,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
648642
649643 for name, modal := range map[string]capturedModal{
650644 "copy search": state.overlays.copySearch,
651- "picker": state.overlays.picker,
652645 "notices": state.overlays.noticesOverlay,
653646 "palette": state.overlays.palette,
654647 "prompt": state.overlays.prompt,
M internal/usecase/daemon/render_test.go +2 -66
@@ -97,29 +97,6 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
9797 }
9898 }
9999
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-
123100 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
124101 p, release := newBlockingPTY(t)
125102 defer release()
@@ -388,12 +365,9 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
388365 d.clock = clock.clock
389366 rc := d.attachCoordinator(target, nil, targetClient, true)
390367
391- // A viewer in a different session is previewing the headless target.
368+ // A viewer in a different session subscribes to the headless target.
392369 viewer := &attachedClient{}
393370 viewer.initOverlays()
394- viewer.overlays.pickerMu.Lock()
395- viewer.overlays.pickerPreview = target.tabs[0]
396- viewer.overlays.pickerMu.Unlock()
397371 d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
398372 previews := make(chan renderWake, 2)
399373 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
@@ -495,25 +469,6 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
495469 })
496470 }
497471
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-
517472 func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
518473 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan wire.Frame) {
519474 t.Helper()
@@ -561,23 +516,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
561516 }
562517 })
563518
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) {
565520 d, sess, tb, p, _ := newFixture(t)
566521 p.screen.Write([]byte("active"))
567522 _ = d.paneRenderable(sess, tb, p)
568523 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")
581524 })
582525 }
583526
@@ -797,13 +740,6 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
797740 visible: "Commands",
798741 notVisible: "Commands",
799742 },
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- },
807743 {
808744 name: "copy mode",
809745 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 {
217217 if !d.prepareParkAttachment(sess, ac) {
218218 return false
219219 }
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)
220224 // A parked attachment has no session owner. Release pane snapshots before
221225 // publishing it in the daemon registry so headless pane/session teardown
222226 // cannot leave closed panes retained through its capture cache. This must
223227 // remain outside d.mu: sendMu is ordered before the daemon lock.
224228 ac.clearCaptureFrames()
225- var pickerGeneration, paletteGeneration uint64
229+ var paletteGeneration uint64
226230 if ac.overlays != nil {
227- ac.overlays.pickerMu.Lock()
228- pickerGeneration = ac.overlays.pickerGeneration
229- ac.overlays.pickerMu.Unlock()
230231 ac.overlays.paletteMu.Lock()
231232 paletteGeneration = ac.overlays.paletteGeneration
232233 ac.overlays.paletteMu.Unlock()
@@ -253,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
253254 grace := d.resumeParkGrace
254255 timer := d.clock.NewTimer(grace)
255256 parked := &parkedAttachment{
256- sess: sess, ac: ac, pickerGeneration: pickerGeneration, paletteGeneration: paletteGeneration,
257+ sess: sess, ac: ac, paletteGeneration: paletteGeneration,
257258 timer: timer, done: make(chan struct{}),
258259 }
259260 ac.parked = true
@@ -297,7 +298,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
297298 }
298299 d.removeParkedLocked(token, parked)
299300 d.mu.Unlock()
300- d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
301301 d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)
302302 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
303303 return
@@ -400,7 +400,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
400400 rearmed := &parkedAttachment{
401401 sess: parked.sess,
402402 ac: ac,
403- pickerGeneration: parked.pickerGeneration,
404403 paletteGeneration: parked.paletteGeneration,
405404 timer: d.clock.NewTimer(d.resumeParkGrace),
406405 done: make(chan struct{}),
@@ -420,7 +419,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
420419
421420 type parkedAttachmentRetirement struct {
422421 parked *parkedAttachment
423- pickerGeneration uint64
424422 paletteGeneration uint64
425423 transport transportSnapshot
426424 }
@@ -434,7 +432,6 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
434432 parked.ac.invalidateRetiredAttachmentCapability()
435433 return parkedAttachmentRetirement{
436434 parked: parked,
437- pickerGeneration: parked.pickerGeneration,
438435 paletteGeneration: parked.paletteGeneration,
439436 transport: parked.ac.transportSnapshot(),
440437 }
@@ -470,7 +467,6 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen
470467 }
471468 retirement.parked.closeDone()
472469 ac := retirement.parked.ac
473- d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
474470 d.closePaletteIfCurrent(ac, retirement.paletteGeneration)
475471 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
476472 }
@@ -559,7 +555,6 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
559555 rc.noteDetach(ac)
560556 }
561557 sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
562- d.unregisterPreview(ac)
563558 if !d.parkAttachment(sess, ac) {
564559 // Detach already published; retire the captured link exactly once so
565560 // 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) {
4545 ref := protocol.RouteRef{Key: 3, Generation: 2}
4646 ac.setRouteAttentionSubscription(protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{{Ref: ref, Target: target, SourceKey: protocol.RemoteInventorySourceKey("remote")}}}, ac.transportSnapshot(), d.clock.Now())
4747 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())
4949 d.refreshRemoteDirectoryViews()
5050 frame := awaitFrame(t, sends, wire.MsgRouteRetired)
5151 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
9696 }
9797
9898 if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted {
99- d.closePicker(effect.ac)
10099 fresh.End()
101100 }
102101 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,
11951195 d.log.Info("tab closed", "session", name)
11961196 markSnapshotDirty(sess)
11971197
1198- d.clearDestroyedTabPreview(tb)
11991198 tb.mu.Lock()
12001199 panes := tb.panesSnapshot()
12011200 tb.mu.Unlock()
@@ -1814,7 +1813,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
18141813 rc.waitForTimerWorkers()
18151814 }
18161815 for _, attachment := range attachments {
1817- d.unregisterPreview(attachment.ac)
18181816 attachment.ac.clearCaptureFrames()
18191817 }
18201818
@@ -1828,7 +1826,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
18281826 sess.clipFiles = nil
18291827 sess.mu.Unlock()
18301828 for _, tb := range tabs {
1831- d.clearDestroyedTabPreview(tb)
18321829 for _, attachment := range attachments {
18331830 d.teardownFloating(tb, attachment.ac)
18341831 }
M internal/usecase/daemon/session_back.go +2 -1
@@ -23,7 +23,8 @@ func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error {
2323 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
2424 return nil
2525 }
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
2728 targetSession, _, live := d.samePeerTarget(protocol.SamePeerSwitchRequest{Target: target})
2829 if live && targetSession != effect.sess {
2930 return offerSamePeerAttachTarget(effect, target)
M internal/usecase/daemon/session_capabilities_test.go +9 -6
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "testing"
66
7+ "github.com/bnema/vev/internal/protocol"
8+
79 "github.com/bnema/vev/internal/domain"
810 )
911
@@ -15,23 +17,24 @@ func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
1517 }
1618 }
1719
18-func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
20+func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) {
1921 d, sess, ac, _ := newManualSessionWithPTYs(t)
2022 sess.caps = sessionCapabilities{cannotYieldMoves: true}
23+ effect := admitPickerEffectForTest(t, sess, ac)
2124
2225 tests := []struct {
2326 name string
24- intent pickerIntent
27+ intent protocol.PickerIntent
2528 wantErr error
2629 }{
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},
2932 }
3033 for _, tt := range tests {
3134 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)
3336 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)
3538 }
3639 })
3740 }
M internal/usecase/daemon/session_view.go +5 -7
@@ -4,7 +4,6 @@ import (
44 "time"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/picker"
87 )
98
109 // viewOptions controls optional (costly) fields captured by snapshotView.
@@ -92,16 +91,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
9291 return view
9392 }
9493
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{
9997 ID: view.id,
10098 Incarnation: view.incarnation,
10199 Name: view.name,
102100 TargetName: view.name,
103101 Active: view.defaultTab,
104- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
102+ Tabs: make([]pickerTabEntry, 0, len(view.tabs)),
105103 }
106104 if !view.ephemeral {
107105 createdAt := view.createdAt
@@ -109,7 +107,7 @@ func (view sessionView) pickerView() picker.SessionView {
109107 }
110108 attention := false
111109 for _, tb := range view.tabs {
112- out.Tabs = append(out.Tabs, picker.TabEntry{
110+ out.Tabs = append(out.Tabs, pickerTabEntry{
113111 TabID: tb.id,
114112 Name: tb.name,
115113 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
283283 if d != nil {
284284 state.attentionFrame = d.attentionFrame()
285285 }
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 {
301287 includeTerminalTitle := true
302288 if d != nil {
303289 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
304290 }
305291 state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
306292 }
307- if !homePicker && cur != nil && d != nil {
293+ if cur != nil && d != nil {
308294 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
309295 }
310296 if ranked != nil {
311297 state.rankedRecent = ranked
312298 } else if routeSnapshot.Generation != 0 {
313- state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot)
299+ state.mru = formatRecentRouteSnapshot(routeSnapshot)
314300 }
315301 return state
316302 }
M internal/usecase/daemon/status_test.go +0 -2
@@ -18,7 +18,6 @@ import (
1818 "github.com/bnema/vev/internal/usecase/command"
1919 "github.com/bnema/vev/internal/usecase/layout"
2020 "github.com/bnema/vev/internal/usecase/palette"
21- "github.com/bnema/vev/internal/usecase/picker"
2221 themeui "github.com/bnema/vev/internal/usecase/theme"
2322 "github.com/bnema/vev/internal/usecase/ui"
2423 )
@@ -1026,7 +1025,6 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
10261025 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
10271026 bars: bars,
10281027 overlays: capturedOverlayRenderState{},
1029- preview: picker.Preview{},
10301028 floatingCfg: domain.FloatingConfig{},
10311029 reset: false,
10321030 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) {
156156 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)
157157 case wire.MsgSamePeerSwitchRequest:
158158 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
159- case wire.MsgParkedRouteRequest:
160- return wire.UnmarshalParkedRouteRequest(frame.Payload)
161159 case wire.MsgNavigationInventoryRequest:
162160 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
163161 case wire.MsgNavigationInventoryPublication:
164162 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
165163 case wire.MsgNavigationInventoryFailure:
166164 return wire.UnmarshalNavigationInventoryFailure(frame.Payload)
165+ case wire.MsgPickerPreviewRequest:
166+ return wire.UnmarshalPickerPreviewRequest(frame.Payload)
167167 case wire.MsgRecentRouteSnapshot:
168168 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
169169 case wire.MsgRouteNavigationFailure:
@@ -296,8 +296,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
296296 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil
297297 case protocol.CommandResult:
298298 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
301299 case protocol.AttachTarget:
302300 return wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(m)}, nil
303301 case protocol.RemotePreview:
@@ -323,8 +321,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
323321 case protocol.SamePeerSwitchFailure:
324322 payload, err := wire.MarshalSamePeerSwitchFailure(m)
325323 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
328324 case protocol.NavigationInventoryResponse:
329325 payload := wire.MarshalNavigationInventoryResponse(m)
330326 if payload == nil {
@@ -343,6 +339,42 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
343339 return wire.Frame{}, errors.New("test server connection: invalid inventory selection")
344340 }
345341 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
346378 default:
347379 return wire.Frame{}, errors.New("test server connection: unsupported server message")
348380 }
@@ -789,44 +821,6 @@ func (t *countingOutputTransport) Capabilities() protocol.ConnectionCapabilities
789821 func (t *countingOutputTransport) LinkState() ports.LinkState { return testLinkState(t) }
790822 func (t *countingOutputTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
791823
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-
830824 func (t failingOutputTransport) ReceiveClient() (protocol.ClientMessage, error) {
831825 return testReceiveClient(t)
832826 }
@@ -861,24 +855,6 @@ func (t cacheFailTransport) Capabilities() protocol.ConnectionCapabilities {
861855 func (t cacheFailTransport) LinkState() ports.LinkState { return testLinkState(t) }
862856 func (t cacheFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
863857
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-
882858 func (t *blockedRenderReplacementTransport) SendOutput(o protocol.Output) error {
883859 return testSendOutput(t, o)
884860 }
@@ -1071,24 +1047,6 @@ func (t *countingOutputTransport) SendOutputSynchronous(o protocol.Output) error
10711047 return testSendOutputSynchronous(t, o)
10721048 }
10731049
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-
10921050 func (t failingOutputTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) }
10931051 func (t failingOutputTransport) SendOutputAsync(o protocol.Output) error {
10941052 return testSendOutputAsync(t, o)
@@ -1104,11 +1062,3 @@ func (t cacheFailTransport) SendOutputAsync(o protocol.Output) error {
11041062 func (t cacheFailTransport) SendOutputSynchronous(o protocol.Output) error {
11051063 return testSendOutputSynchronous(t, o)
11061064 }
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) {
1212 for _, actionID := range []uint64{0, 17} {
1313 for _, message := range []protocol.ServerMessage{
1414 protocol.AttachTarget{CauseActionID: 999, Session: "destination", Intent: protocol.IntentAttach},
15- protocol.NavigationDirective{CauseActionID: 999, Action: protocol.NavigationBack},
1615 protocol.RouteNavigationAction{CauseActionID: 999, SnapshotGeneration: 1, Key: 2, Generation: 3},
1716 protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"},
1817 } {
@@ -41,8 +40,6 @@ func testNavigationName(message protocol.ServerMessage, actionID uint64) string
4140 switch message.(type) {
4241 case protocol.AttachTarget:
4342 return name + "attach"
44- case protocol.NavigationDirective:
45- return name + "directive"
4643 case protocol.RouteNavigationAction:
4744 return name + "recent"
4845 default:
@@ -57,10 +54,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 {
5754 message, err := wire.UnmarshalAttachTarget(frame.Payload)
5855 require.NoError(t, err)
5956 return message.CauseActionID
60- case wire.MsgNavigationAction:
61- message, err := wire.UnmarshalNavigationDirective(frame.Payload)
62- require.NoError(t, err)
63- return message.CauseActionID
6457 case wire.MsgNavigateRecentRoute:
6558 message, err := wire.UnmarshalRouteNavigationAction(frame.Payload)
6659 require.NoError(t, err)
@@ -75,44 +68,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 {
7568 }
7669 }
7770
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-
11671 func TestUIActionInputPaletteSubmissionCarriesItsOwnCause(t *testing.T) {
11772 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
11873 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
341341 return
342342 }
343343 state.registryDone = true
344+ wasInitialized := state.registryInit
344345 state.registryInit = true
345346 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".
347351 state.dirty = true
348352 }
349353 if expedited {
M internal/usecase/remotes/monitor_test.go +20 -0 collapsed to keep the page light
A internal/usecase/remotes/registry.go +98 -0
@@ -0,0 +1,98 @@
1+package remotes
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// This file owns the runner-scoped client host registry: the endpoint bindings
12+// one client reuses across handoffs, plus the discovery projection the same
13+// client subscribes to. It sits in front of the remote monitor, so a runner
14+// starts and joins exactly one discovery loop no matter how many serving
15+// daemons, sessions, or picker interactions it passes through.
16+
17+// ErrInvalidEndpoint reports a request the registry cannot resolve because its
18+// endpoint identity is empty. It is never a transport or policy failure.
19+var ErrInvalidEndpoint = errors.New("vev: invalid remote endpoint")
20+
21+// HostRegistry resolves endpoints into reusable client carriages and forwards
22+// the discovery projection. One registry belongs to one runner: its bindings
23+// are valid only for that runner's frozen launch policy, and Run owns the
24+// discovery loop until the runner exits.
25+type HostRegistry struct {
26+ factory ports.RemoteEndpointFactory
27+
28+ mu sync.Mutex
29+ bindings map[string]ports.RemoteEndpointBinding
30+}
31+
32+// NewHostRegistry wires an endpoint cache. Construction performs no I/O.
33+func NewHostRegistry(factory ports.RemoteEndpointFactory) *HostRegistry {
34+ return &HostRegistry{
35+ factory: factory,
36+ bindings: make(map[string]ports.RemoteEndpointBinding),
37+ }
38+}
39+
40+// ResolveEndpoint returns the endpoint's binding, resolving it once and reusing
41+// it for every later handoff to that endpoint. A failed resolution is never
42+// cached, so a later attempt retries through the same single authority instead
43+// of falling back to another resolver.
44+func (r *HostRegistry) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
45+ if r == nil {
46+ return ports.RemoteEndpointBinding{}, ErrInvalidEndpoint
47+ }
48+ if endpoint == "" {
49+ return ports.RemoteEndpointBinding{}, ErrInvalidEndpoint
50+ }
51+ r.mu.Lock()
52+ cached, ok := r.bindings[endpoint]
53+ r.mu.Unlock()
54+ if ok {
55+ return bindingCopy(cached), nil
56+ }
57+ if r.factory == nil {
58+ return ports.RemoteEndpointBinding{}, ErrInvalidEndpoint
59+ }
60+ resolved, err := r.factory.ResolveEndpoint(ctx, endpoint)
61+ if err != nil {
62+ return ports.RemoteEndpointBinding{}, err
63+ }
64+ if resolved.Dialer == nil {
65+ return ports.RemoteEndpointBinding{}, ErrInvalidEndpoint
66+ }
67+ // The binding is stored and handed out defensively: a caller that mutates
68+ // the environment it received must not change the next handoff's.
69+ r.mu.Lock()
70+ if existing, ok := r.bindings[endpoint]; ok {
71+ // A concurrent resolve won the race; both are equivalent by policy.
72+ resolved = existing
73+ } else {
74+ r.bindings[endpoint] = bindingCopy(resolved)
75+ }
76+ r.mu.Unlock()
77+ return bindingCopy(resolved), nil
78+}
79+
80+// bindingCopy returns a binding whose environment the caller owns. A nil
81+// environment stays nil: an endpoint with no explicit environment is not the
82+// same as one configured with an empty environment.
83+func bindingCopy(binding ports.RemoteEndpointBinding) ports.RemoteEndpointBinding {
84+ if binding.Environment == nil {
85+ return ports.RemoteEndpointBinding{Dialer: binding.Dialer}
86+ }
87+ return ports.RemoteEndpointBinding{
88+ Dialer: binding.Dialer,
89+ Environment: append([]string{}, binding.Environment...),
90+ }
91+}
92+
93+// noDirectorySubscription is the inert subscription of a registry without a
94+// directory: it never wakes and closes without effect.
95+type noDirectorySubscription struct{}
96+
97+func (noDirectorySubscription) Changed() <-chan struct{} { return nil }
98+func (noDirectorySubscription) Close() {}
A internal/usecase/remotes/registry_test.go +206 -0 collapsed to keep the page light
M scripts/remote-picker-harness/README.md +29 -8 collapsed to keep the page light
A scripts/remote-picker-harness/acceptance.py +343 -0 collapsed to keep the page light
M scripts/remote-picker-harness/run.sh +17 -0 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close