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
12 go test ./internal/usecase/daemon -race -run TestName12 go test ./internal/usecase/daemon -race -run TestName
13 make lint13 make lint
14 make mocks14 make mocks
15-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem15+go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
16 ```16 ```
17 17
18 - Format with **goimports**.18 - Format with **goimports**.
@@ -68,7 +68,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
68 Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Message IDs, frames, strict codecs, compression, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`.68 Typed messages and negotiated version live in `internal/protocol`. Remote discovery JSON lives in `internal/protocol/catalogue`. Message IDs, frames, strict codecs, compression, encoded bounds, and raw carriage interfaces live in `internal/protocol/wire`. Connection framing lives in concrete carriage adapters such as `internal/adapters/ipc`.
69 69
70 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.70 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
71-- Client message types occupy `1–13`, `15`, `32–33`, and `35` (`MsgParkedRouteRequest`); server types occupy `16–23`, `25–31`, `34`, and `36` (`MsgParkedRouteResponse`). Types `14` and `24` remain reserved.71+- Client message types occupy `1–13`, `15`, `32–33`, `35` (`MsgParkedRouteRequest`), and `49`/`51`/`53` (`MsgPickerOpen`/`MsgPickerCloseClient`/`MsgPickerSelection`); server types occupy `16–23`, `25–31`, `34`, `36` (`MsgParkedRouteResponse`), and `50`/`52`/`54` (`MsgPickerSnapshot`/`MsgPickerCloseServer`/`MsgPickerFailure`). Types `14` and `24` remain reserved.
72 - Version negotiation requires strict equality.72 - Version negotiation requires strict equality.
73 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.73 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
74 - Bump `ProtocolVersion` for any message layout change.74 - Bump `ProtocolVersion` for any message layout change.
D docs/ansi-content-replay-phase0.md +0 -11
@@ -1,11 +0,0 @@
1-# Phase 0: ANSI content replay gate
2-
3-The former test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` recorded the feasibility boundary for remote content. That prototype was removed when direct remote ownership became the production boundary; the constraints it established remain:
4-
5-- ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.
6-- The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.
7-- Full and incremental output, styles, wide-cell continuation, cursor state, and split CSI/OSC writes are captured as cells and cursor metadata.
8-- The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.
9-- A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.
10-
11-This remains feasibility evidence only. Direct remote attachments keep the remote daemon as the owner of the renderer, protocol, layout, and attach flow; no ANSI replay or local shadow-session compatibility path is implied.
M docs/architecture.md +23 -1
@@ -42,6 +42,28 @@ Interactive observation is a separate opt-in composition. `term.Terminal` remain
42 42
43 `--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.43 `--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract.
44 44
45+## Client-owned picker presentation
46+
47+Every attachment presents the navigation picker itself. The serving daemon publishes a typed interaction instead of installing an overlay: `PickerOffer` names the intent and the acquisition barrier, `PickerSnapshot` carries opaque row keys plus display text, `PickerSelection` commits a key at the displayed revision, `PickerClose` retires the interaction in either direction, and `PickerFailure` reports a bounded rejection. The daemon owns no presentation model: it keeps facts, eligibility, and mutations, and resolves committed keys to navigation, move, or kill targets.
48+
49+Source eligibility travels with every row. `PickerLine.Focusable` is the source's authorisation to rest the cursor on that row, while `PickerLine.Actions` is what the row may commit; an action row is always focusable, a section header never is, and a focusable row without actions is published for inspection only. The client's cursor visits focusable rows, narrowed by its own search query, so it never lands on a row the source skipped and the footer never promises a commit the cursor cannot make.
50+
51+The client owns who writes the terminal for the interaction lifetime, while the daemon keeps mutation authority and resolves keys. `pickerLease` in `internal/usecase/client` tracks the presentation states: a snapshot is admitted only once the client applied output through the barrier epoch/state it carries, daemon paints are then applied to the output shadow and acknowledged inside the bounded window without being written, and the interaction is released only by a full paint accepted after the close boundary. Locally composed frames and repaints go through the same single writer, UI transaction, and flush as daemon output, so a frame is never claimed before it is displayed.
52+
53+Input ownership is exclusive for the interaction lifetime. `pickerConsumer` in `internal/usecase/client` publishes the current owner to the stdin pump: while the picker owns input the pump decodes physical bytes and admitted automation batches into identified operations and the attach loop, its single model writer, applies them. Nothing falls back to the session pipeline: unknown, oversized, and paste-sized batches are consumed, a multi-key batch is never applied as a command, and an ambiguous escape prefix is bounded by a short deadline instead of being forwarded. The serving daemon mirrors the rule: while `pickerOpen` it drops raw key and mouse input for that attachment (`handleInput`/`handleInputForAttachment` return before the mouse scanner and the key router), so only typed `PickerSelection`, `PickerClose`, and `PickerPreviewRequest` can act. A terminal close — a resolved selection, a cancel, a stale-revision or retired-target rejection, or a superseding overlay — sends `PickerClose` when the client must retire the interaction itself, keeps consuming and dropping input until the daemon's authoritative repaint lands, and only then resumes normal routing.
54+
55+### Row previews
56+
57+A preview is data, not a daemon paint: the client asks for the row its cursor rests on with a debounced `PickerPreviewRequest` (80 ms, one request in flight per interaction), and the serving daemon answers with a bounded styled-cell `PickerPreview`. The daemon captures local rows through the navigation preview facts it already owns (`previewTarget` plus `snapshotPickerPreview`), captures remote rows through its own remote viewport client and cache, and registers the requested row as that viewer's render subscription, so a row that keeps changing keeps publishing while a superseded request publishes nothing. Unchanged or retired rows answer with a status-only preview, and the client clears its viewport rather than showing another row's content. The client composes the viewport into its own modal; the daemon never writes it.
58+
59+### Attention and notifications
60+
61+Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows.
62+
63+## Remote hosts
64+
65+Remote discovery has two owners with separated writers. The daemon composes its own store, catalogue client, cache, and monitor for the structured rows and exact-target validation its picker serves. The launching client composes a runner-scoped `remotes.HostRegistry` over its own store, catalogue client, and a runner-local catalogue cache that seeds from the durable snapshot and never writes it, so one monitor can never truncate the other's file. The registry projects that discovery through `ports.RemoteDirectory` and resolves each endpoint once into a `ports.RemoteEndpointBinding` — the typed dialer every session on that endpoint attaches through, plus the environment to advertise — which the runner reuses for its initial remote attach and for every later handoff. Transport mode, launch allowlist, and endpoint environment stay in `internal/app` behind `ports.RemoteEndpointFactory`; the client never inspects them, and a resolution failure is never cached, so a refused endpoint fails the handoff instead of falling back to another resolver.
66+
45 ## Session composition67 ## Session composition
46 68
47 ```text69 ```text
@@ -61,4 +83,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
61 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.83 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
62 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.84 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
63 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.85 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
64-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `43`, including source-scoped route subscriptions and lifecycle retirement).86+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `48`, including the client-picker interaction and its preview pair).
A docs/plans/client-preferences-contract.md +83 -0
@@ -0,0 +1,83 @@
1+# Client preferences contract (P4.1, design-only)
2+
3+Status: proposed. No production edits accompany this document. Any scope
4+change below requires explicit approval before implementation.
5+
6+## Ground rules
7+
8+- Shared pane VT defaults stay daemon-arbitrated. `applyHostThemeLocked`
9+ (`internal/usecase/daemon/theme_resolve.go`) fans the resolved theme out to
10+ every pane VT in every tab of the session; per-attachment values never reach
11+ pane screens.
12+- Picker sort scope is unchanged: `togglePickerSort` flips the daemon-wide
13+ `pickerSort` atomic (`internal/usecase/daemon/picker.go:40`); it is session
14+ chrome, not an attachment preference.
15+- Bindings stay daemon-admitted. `keys.BuildBindingEntries` +
16+ `keys.Router.RouteWithHandler` (`internal/usecase/keys`) remain the single
17+ admission path; no duplicate client-side key router is proposed.
18+- Config source of truth stays the daemon-loaded file parsed by
19+ `internal/adapters/config` into `domain.Config`, published atomically via
20+ `Daemon.ApplyConfig` snapshots (`internal/usecase/daemon/config.go`).
21+
22+## Classes
23+
24+- **Client-local interaction**: affects only the local terminal device, never
25+ pane VT state, session state, or another attachment. The only class eligible
26+ for client-side ownership without a P5 lease.
27+- **Attachment-rendering**: affects presentation of one attachment, executed by
28+ the serving daemon from per-attachment state. Shared session/pane state
29+ untouched.
30+- **Shared application-visible**: affects panes, sessions, or all attachments.
31+ Daemon-owned; not candidates for client ownership in this plan.
32+
33+## Field classification
34+
35+All keys are the existing `internal/adapters/config` keys. "Two-attachment
36+result" describes two simultaneous attachments A and B on the same session
37+with different local wishes.
38+
39+| Key | Class | Source / default / reload / validation | Two-attachment result |
40+|---|---|---|---|
41+| `theme`, `theme.palette`, `theme.accent` | Shared | Daemon config file; defaults `auto`/`on`/`auto` (`domain.Defaults`); atomic reload via `storeThemeConfig`, reapplied with `reapplyThemeAllSessions`; invalid values warn and keep prior (`config_test.go`) | Single effective theme per session (`effectiveThemeForConfig`, `resolveAppliedTheme`); per-attachment client theme is only an input to arbitration, never applied directly to panes. **Preserved as-is.** |
42+| `scrollback.*` | Shared | Daemon config; defaults 50 MiB / 10k lines; `Scrollback.Valid()` falls back to defaults with a warning | Per-pane retention policy; identical for every viewer. **Preserved as-is.** |
43+| `snapshot.*` | Shared | Daemon config; allowlist snapshot via `restoreProcessAllowlistFromConfig` | Session restore; identical for every viewer. **Preserved as-is.** |
44+| `floating.command`, `floating.width`, `floating.height` | Shared | Daemon config; defaults 80x80; stored via `floatingConfig` | Geometry arbitration is daemon-owned (`calculateContentFloatingGeometry`). **Preserved as-is.** |
45+| `bar.top-right`, `bar.bottom-right`, `bar.interval` | Shared | Daemon config; defaults empty/empty/5s, clamped to `MinBarInterval` (1s) | Daemon-host execution, per-session outputs. Governed by the status contract; **no client execution proposed here.** |
46+| `code.*`, `binding.*` | Shared | Daemon config; `buildCodeOverrides` + `BuildBindingEntries`, warn-and-keep on invalid | Daemon-admitted palette commands and key bindings. **Single router preserved.** |
47+| `copy.word-separators`, `copy.reduce-motion` | Shared (daemon-executed attached interaction) | Daemon config; defaults ` -_@` / false; `currentCopyConfig` snapshot | Copy mode stays daemon-owned per plan non-goals. Selection documents (`scopy.NewDocument`) and scroll animation execute in the daemon. **Not candidates; moving them requires the P5 lease.** |
48+| `nav.overflow-tabs`, `nav.overflow-sessions` | Shared (attached-action behavior) | Daemon config; defaults false; `currentNavConfig` | Directional navigation is daemon-admitted (`pane_actions.go:465`). **Not candidates.** |
49+| `tabs.terminal-title` | Attachment-rendering | Daemon config; default true; `currentTabsConfig` snapshot read at status composition (`status.go:303,334`, `picker.go:96`) | Label-only; per-attachment divergence is safe in principle, but no per-attachment source exists today. **Proposed future field (example): read from attachment claim, defaulting to daemon config. Requires approval; not implemented here.** |
50+| `palette.anchor` | Attachment-rendering | Daemon config; default center; `currentPaletteConfig` read at render (`render.go:443`) | Placement-only; same future-field shape as above. **Requires approval; not implemented here.** |
51+
52+## Proposed fields and examples
53+
54+No new configuration keys are introduced by this contract. The only proposed
55+shape, pending approval, is: an attachment-rendering value resolves as
56+*attachment-claimed value if present, else daemon config*, read at composition
57+time from existing snapshots, never written to `domain.Config` or persisted.
58+`tabs.terminal-title` and `palette.anchor` are the worked examples above.
59+
60+Explicitly out of scope: client-local clipboard policy, client-side theme
61+editing, per-attachment scrollback, per-attachment bindings, and any new
62+`domain.Config` field. Each would need its own approved contract.
63+
64+## Tests mapped (to be added only after approval)
65+
66+- `TestAttachmentRenderingPreferenceFallsBackToDaemonConfig` (daemon):
67+ attachment without claim renders with daemon `palette.anchor` /
68+ `tabs.terminal-title`.
69+- `TestAttachmentRenderingPreferenceClaimWins` (daemon): claimed value used,
70+ shared pane VT colors byte-identical to unclaimed control.
71+- `TestTwoAttachmentsIndependentRenderingPrefs` (daemon): A and B on one
72+ session render different anchors/titles; pane screens identical.
73+- `TestInvalidRenderingPreferenceWarnsAndKeepsPrior` (config + daemon):
74+ warn-and-continue path mirrors existing theme/scrollback tests.
75+- Existing preserved-behavior pins: `TestDefaults*`, `TestParse*` in
76+ `internal/adapters/config`, `config_test.go` snapshot tests,
77+ `picker_test.go` sort tests, `theme_integration_test.go`.
78+
79+## Stop condition
80+
81+P4.1 ends at this document. No preference scope change, no `domain.Config`
82+field, no wire field, and no client ownership move is authorized by this
83+plan alone.
A docs/plans/client-status-contract.md +138 -0
@@ -0,0 +1,138 @@
1+# Client status contract (P4.2, design-only)
2+
3+Status: proposed. No production edits accompany this document. No provider
4+implementation is authorized by this plan alone; a runnable task breakdown
5+follows only after contract approval.
6+
7+## Current daemon-host behavior (preserved)
8+
9+- Commands come only from the daemon-loaded config: `bar.top-right`,
10+ `bar.bottom-right`, `bar.interval` (defaults empty/empty/5s,
11+ `internal/adapters/config`, `domain.Defaults`).
12+- Execution is daemon-host via `barScriptRunner.run`
13+ (`internal/usecase/daemon/bar_script.go`) with `ports.ShellCommandRunner`:
14+ timeout 1s (`barScriptTimeout`), stdout cap 1024, stderr cap 512, display
15+ cap 256, first-line + `stripANSIAndControls` sanitization, warn-once
16+ failure dedup (`lastFailure`).
17+- Scheduling is per-session in `bar_refresh.go`: poller on the daemon clock
18+ with `MinBarInterval` (1s) floor, `running`/`pending` coalescing, context
19+ change (`lastContext`) forces refresh, `reload` channel wakes the poller on
20+ config change, `refreshBarScriptsAllSessions` applies new commands
21+ immediately.
22+- Scripts observe daemon-side context only: `VEV_ANCHOR`, `VEV_SESSION`,
23+ `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, `VEV_COLS`.
24+- Composition reads snapshots at paint time: `barScriptSnapshot(sess)` feeds
25+ `barState.topRight`/`bottomRight` alongside `status`, `statusFeedback`,
26+ `mru`/`rankedRecent`, `attentionFrame` (`status.go:161,308,334`).
27+- The client has no status-provider code today: no reference to
28+ `topRight`/`bottomRight` exists under `internal/usecase/client`. The client
29+ renders daemon-published frames verbatim. This contract does not change
30+ that until its implementation is approved.
31+
32+## Opt-in client-local provider contract
33+
34+### Configuration authority
35+
36+- The command string for a client-local provider comes exclusively from the
37+ **local** configuration authority (the config file loaded by the local
38+ process composition). A remote daemon MUST NOT supply a shell command over
39+ the wire; the wire carries only rendered text values (see fields below).
40+- Existing `bar.top-right` / `bar.bottom-right` scripts remain daemon-host
41+ execution with unchanged semantics. Client-local providers target the same
42+ two slots but never replace daemon execution implicitly: both run, and
43+ precedence (below) decides what paints.
44+- Opt-in per slot: unset means absent. No implicit fallback to a remote path,
45+ and remote pane CWD is never interpreted as a local path. A provider that
46+ needs a working directory uses only locally configured values.
47+
48+### Generations
49+
50+- **Provider generation**: a monotonically increasing epoch owned by the
51+ executing side, bumped on every configuration reload affecting the slot.
52+ Stale values (older provider generation than the latest admitted for that
53+ slot and attachment) are discarded, never painted.
54+- **Attachment generation**: the existing attachment identity/generation used
55+ in output arbitration binds a value to the attachment it was produced for.
56+ A value admitted for attachment A is never painted on attachment B; on
57+ navigation/switch, pending provider values for the retired attachment are
58+ dropped with the foreground owner.
59+
60+### Bounds, queues, cancellation
61+
62+- Keep the existing daemon bounds unless separately approved: 1s timeout,
63+ 1024-byte stdout cap, 512-byte stderr cap, 256-rune display cap.
64+ Client-local execution adopts the same caps.
65+- Bounded work queue per attachment (proposed depth 1 with coalescing,
66+ mirroring `running`/`pending`): a slow provider run never stacks; a newer
67+ tick marks pending and supersedes.
68+- Cancellation: provider runs cancel on attachment retirement, slot
69+ reconfiguration, and client shutdown via the existing foreground-lifetime
70+ mechanism. A late completion after cancel is discarded.
71+
72+### Sanitization and error presentation
73+
74+- All provider output passes daemon-side-equivalent sanitization on the
75+ executing side before admission: first line, valid UTF-8, ANSI/control
76+ stripping (`sanitizeBarScriptOutput` semantics), display-cap truncation.
77+- Stale/error presentation is deterministic: on failure the slot keeps the
78+ last good value; a slot with no good value renders empty. Failures warn
79+ once per (provider, slot, signature) until the signature changes. No error
80+ text from stderr is painted (stderr is diagnostics only, capped).
81+
82+### Precedence (same slot, daemon + client values)
83+
84+1. A fresh admitted daemon value and a fresh admitted client value for the
85+ same slot: the **client-local value wins only when the slot is explicitly
86+ configured for client-local authority**; otherwise the daemon value wins.
87+ Authority is per slot, from local configuration, never negotiated.
88+2. A stale value (older generation) never overrides a fresh one regardless
89+ of side.
90+3. Absent/unset on the winning side falls through to the other side's latest
91+ admitted value, then to empty.
92+
93+## Proposed fields (exact, pending approval)
94+
95+Configuration (local file, new keys under the existing parser):
96+
97+- `status.top-right.provider = local | daemon` (default `daemon`)
98+- `status.top-right.command = "<shell>"` (default empty = absent)
99+- `status.bottom-right.provider`, `status.bottom-right.command`: same shape.
100+- `status.interval`: duration, default 5s, floor 1s (`MinBarInterval`);
101+ invalid values warn and keep prior, mirroring `bar.interval`.
102+
103+Wire/schema (rendered text only, never commands):
104+
105+- `StatusSlotValue{Slot: top-right | bottom-right, Text: string (already
106+ sanitized, ≤256 runes), ProviderGeneration: uint64,
107+ AttachmentID: <existing attachment identity type>}` carried inside the
108+ existing publication path for the owning attachment only.
109+- No new carriage primitive: values travel with the attachment's ordered
110+ publication; no persistence-format change.
111+
112+Validation: unknown `provider` value warns and keeps `daemon`; empty command
113+disables the slot; interval below minimum clamps and warns.
114+
115+## Tests mapped (to be added only after approval)
116+
117+- `TestClientProviderCommandNeverComesFromWire` (sessionwire/client):
118+ crafted inbound value with command content is rejected; only text fields
119+ admitted.
120+- `TestStaleProviderGenerationDiscarded` (client/composition): older
121+ provider generation never paints over a newer admitted value.
122+- `TestProviderValueBoundToAttachment` (client): value admitted for A never
123+ paints on B; retirement drops pending values.
124+- `TestProviderPrecedenceDaemonWinsByDefault` / `TestProviderPrecedenceLocalWinsWhenConfigured`
125+ (composition): per-slot authority matrix.
126+- `TestProviderFailureKeepsLastGood` / `TestProviderEmptyWhenNoGood`
127+ (provider harness): deterministic stale/error presentation; warn-once.
128+- `TestProviderQueueCoalesces` (provider harness, fake clock): slow run +
129+ newer tick yields at most one pending execution; cancel on retirement.
130+- `TestRemoteCwdNeverInterpretedLocally` (provider harness): provider env
131+ contains no remote-derived path.
132+- Existing pins stay green: `bar_refresh_test.go`, `bar_script` tests,
133+ `status` composition tests, config parse tests.
134+
135+## Stop condition
136+
137+P4.2 ends at this document. No provider implementation, no new config key,
138+no wire field, and no new dependency is authorized by this plan alone.
M docs/session-navigation.md +8 -0
@@ -45,6 +45,14 @@ startup.
45 cause while the originating interaction is still open; stale failures45 cause while the originating interaction is still open; stale failures
46 from closed interactions drop. Native commands stay usable throughout.46 from closed interactions drop. Native commands stay usable throughout.
47 47
48+## Client-owned picker
49+
50+The navigation picker runs as a typed client interaction when the serving attachment advertises the client-picker capability: the palette command `SSP` (session picker) opens it, and a client with a home route answers that command with a home-picker navigation directive instead, so the picker stays daemon-rendered on the home route.
51+
52+The client renders the picker frame from the admitted snapshot rows, moves the cursor and filters with `/` locally, and sends only a typed selection (opaque row key plus displayed revision) or a close. The serving daemon re-resolves the key, revalidates the target lifecycle, and performs the unchanged navigation handoff; a selection must carry exactly the displayed revision, and a stale revision, unknown key, retired target, or failed navigation returns a typed failure instead of committing. Escape closes the interaction without selecting, `s` (sort) and `x` (kill) are unavailable in this mode, and the cursor/search state is local to the interaction.
53+
54+While the picker is open it owns terminal input on every path: physical keys and admitted automation batches reach the client picker and never the session, and the serving daemon drops raw key and mouse input for that attachment for the same window. After a close, the client keeps consuming and dropping input until the daemon's authoritative repaint is displayed, so nothing typed at the picker is replayed into the shell; routing resumes at that boundary. If the attachment generation changes before that repaint (a reconnect), the lease is aborted with the attachment and the new generation starts from normal routing.
55+
48 ## Remote vision56 ## Remote vision
49 57
50 Remote-origin rows display availability tags (`down`, `broken`, stopped)58 Remote-origin rows display availability tags (`down`, `broken`, stopped)
D docs/vt-extraction-preparation.md +0 -39
@@ -1,39 +0,0 @@
1-# VT extraction preparation checkpoint
2-
3-The standalone module owns the frontend-neutral VT engine and ANSI renderer;
4-vev consumes the released `github.com/bnema/vev-vt v0.2.1` module. VT behavior,
5-renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain
6-unchanged.
7-
8-## Evidence
9-
10-- The feature worktree was created from a clean checkout.
11-- `make test` passes (`go test ./... -race`).
12-- `make lint` passes (`goimports` has no changes and `go vet ./...` passes).
13-- External-package contract tests pass under `-race` for parsing, callbacks,
14- borrowing, snapshots, history views, stable chunk identity, and single-owner
15- usage.
16-- VTH3 golden vectors round-trip byte-for-byte across empty, plain,
17- continuation, styled/RGB, bounded, and line-bound cases. Truncation,
18- corruption, invalid fields, and trailing bytes are rejected.
19-- The VEVS v4 restore envelope golden decodes through the existing restore path,
20- including sealed history, tail history, and recovery transcript blobs.
21- Truncated, corrupted, trailing, and malformed nested VT payloads are rejected.
22-- Representative VT/history/snapshot benchmarks pass; benchmark output is
23- retained in the implementation run rather than committed as machine-specific
24- data.
25-- `git diff --check` passes.
26-- The standalone module owns the frontend-neutral Cell, Style, RGB, Frame,
27- Damage, RuneWidth, VT parser, history, snapshots, and ANSI output packages.
28-- The cutover has no local VT, core, ANSI, or renderer implementation owner;
29- vev resolves the released module through normal module download semantics.
30-- A standalone core-only frame consumer test builds ANSI output without
31- importing the VT parser.
32-
33-## Scope guard
34-
35-The vev cutover changes only module ownership and import paths; no VT parser
36-behavior, VTH3/VEVS bytes, or wire/protocol definition changed. There is no
37-committed `replace` directive or compatibility owner in vev. Any unrelated
38-behavior, wire, or dependency change discovered while maintaining the standalone
39-module must be split into a separate plan.
D docs/vt-extraction-verification.md +0 -44
@@ -1,44 +0,0 @@
1-# VT extraction verification evidence
2-
3-## Release
4-
5-- Module: `github.com/bnema/vev-vt v0.2.1`
6-- Module checksum: `h1:sb5XWVVIgiYd82KSYb3gHvkHVt9r5oRoEK0hqO1B+Aw=`
7-- Go module checksum: `h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA=`
8-- Standalone release commit: `7bd1a3c4`
9-- vev cutover commit: `3fd4a02d`
10-
11-## Acceptance evidence
12-
13-- vev: `make test`, `make lint`, `go mod verify`, and clean-checkout module
14- resolution pass without a `replace` directive.
15-- Standalone module: tests, race tests, `go vet`, `golangci-lint`, external
16- consumer smoke, import-boundary checks, and signed release tag pass.
17-- Fuzz smoke passes for VT input, ANSI rendering, VTH3 decoding, and VEVS v4
18- restore decoding. Seeds include escape sequences, Unicode, clipboard input,
19- malformed history, and the VEVS restore golden.
20-- VTH3 and VEVS fixtures remain byte-compatible. VEVS framing and session policy
21- remain vev-owned.
22-- History/codec benchmark allocations and bytes are unchanged in the selected
23- pre/post comparison. Representative daemon frame benchmarks remained within
24- normal run variance; no output-byte regression was observed.
25-
26-## Diff classification
27-
28-- **Extraction:** standalone module release, vev module dependency cutover, and
29- removal of the in-repository VT/core/ANSI owners.
30-- **Tests and fixtures:** external ownership contracts, VTH3 golden vectors,
31- VEVS restore fixtures, and fuzz smoke coverage.
32-- **Documentation:** ownership, callback, borrowing, release, and verification
33- records.
34-- **CI:** standalone formatting, lint, race, fuzz, benchmark, and consumer jobs.
35-
36-## Risks and follow-up
37-
38-- A broad baseline run including `BenchmarkScreenResizeReflowViewport` still
39- exposes the pre-existing `invalid history row ID` benchmark panic. The
40- selected extraction comparison excludes that benchmark rather than masking it.
41-- There is no dedicated standalone `RuneWidth` benchmark; width behavior is
42- covered by public contract tests and the screen/parser benchmark suite.
43-- The outer VEVS codec intentionally remains in vev and must not move into the
44- standalone module.
A internal/adapters/remote/memory_catalog_cache.go +85 -0
@@ -0,0 +1,85 @@
1+package remote
2+
3+import (
4+ "sync"
5+
6+ appports "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/protocol/catalogue"
8+)
9+
10+// SeededCatalogCache is a runner-local remote catalogue cache: it starts from
11+// the durable cache's entries and only ever writes in memory. A client monitor
12+// uses it so its own observations can never race, truncate, or overwrite the
13+// daemon's whole-directory snapshot file.
14+type SeededCatalogCache struct {
15+ seed appports.RemoteCatalogCache
16+
17+ mu sync.Mutex
18+ loaded bool
19+ entries []catalogue.RemoteCatalogCacheEntry
20+ loadErr error
21+}
22+
23+var _ appports.RemoteCatalogCache = (*SeededCatalogCache)(nil)
24+
25+// NewSeededCatalogCache wraps the durable cache. Construction performs no I/O:
26+// the seed is read on the first Load.
27+func NewSeededCatalogCache(seed appports.RemoteCatalogCache) *SeededCatalogCache {
28+ return &SeededCatalogCache{seed: seed}
29+}
30+
31+// Load returns the in-memory entries, reading the durable seed once.
32+func (c *SeededCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
33+ if c == nil {
34+ return nil, nil
35+ }
36+ c.mu.Lock()
37+ defer c.mu.Unlock()
38+ if !c.loaded {
39+ c.loaded = true
40+ if c.seed != nil {
41+ entries, err := c.seed.Load()
42+ if err != nil {
43+ // A failed seed read is reported once and never retried: the
44+ // client monitor must not hammer a broken cache file.
45+ c.loadErr = err
46+ } else {
47+ c.entries = cloneCatalogEntries(entries)
48+ }
49+ }
50+ }
51+ if c.loadErr != nil {
52+ return nil, c.loadErr
53+ }
54+ return cloneCatalogEntries(c.entries), nil
55+}
56+
57+// Store replaces the in-memory entries. Nothing reaches the durable file.
58+func (c *SeededCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
59+ if c == nil {
60+ return nil
61+ }
62+ c.mu.Lock()
63+ defer c.mu.Unlock()
64+ c.loaded = true
65+ c.entries = cloneCatalogEntries(entries)
66+ return nil
67+}
68+
69+// cloneCatalogEntries returns a defensive copy of the entries, including the
70+// nested session and tab slices the monitor publishes from.
71+func cloneCatalogEntries(entries []catalogue.RemoteCatalogCacheEntry) []catalogue.RemoteCatalogCacheEntry {
72+ if entries == nil {
73+ return nil
74+ }
75+ out := make([]catalogue.RemoteCatalogCacheEntry, len(entries))
76+ for i, entry := range entries {
77+ out[i] = entry
78+ out[i].Sessions = make([]catalogue.RemoteCatalogSession, len(entry.Sessions))
79+ for j, session := range entry.Sessions {
80+ out[i].Sessions[j] = session
81+ out[i].Sessions[j].Tabs = append([]catalogue.RemoteCatalogTab(nil), session.Tabs...)
82+ }
83+ }
84+ return out
85+}
M internal/adapters/remote/runtime_test.go +21 -14
@@ -182,17 +182,24 @@ func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) {
182 t.Fatalf("observes = %d, want 4", observes)182 t.Fatalf("observes = %d, want 4", observes)
183 }183 }
184 184
185- // A second store while the writer is occupied queues behind it; a third185+ // Two further stores arrive while the writer is occupied. The lane admits
186- // concurrent store cannot enter the lane and retires with a lane-busy186+ // exactly one of them and retires the other as lane-busy rather than
187- // failure instead of wedging dispatch. The service never issues either:187+ // queueing without bound; which of the two wins is admission timing, so
188- // it keeps one active write plus one replaceable pending snapshot.188+ // the assertions follow the reported outcome instead of naming a winner.
189- second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}}189+ second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "second", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}}
190- third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}}190+ third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "third", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}}
191 jobs <- second191 jobs <- second
192 jobs <- third192 jobs <- third
193- duplicate := receiveResult(t, results, "duplicate store")193+ busy := receiveResult(t, results, "lane-busy store")
194- if duplicate.Kind != appports.RemoteJobCacheStore || !errors.Is(duplicate.Err, errRemoteLaneBusy) {194+ if busy.Kind != appports.RemoteJobCacheStore || !errors.Is(busy.Err, errRemoteLaneBusy) {
195- t.Fatalf("duplicate store result = %+v, want lane-busy failure", duplicate)195+ t.Fatalf("bounded-lane store result = %+v, want lane-busy failure", busy)
196+ }
197+ if busy.Endpoint != second.Endpoint && busy.Endpoint != third.Endpoint {
198+ t.Fatalf("lane-busy store endpoint = %q, want one of the two admitted stores", busy.Endpoint)
199+ }
200+ admitted := second.Endpoint
201+ if busy.Endpoint == second.Endpoint {
202+ admitted = third.Endpoint
196 }203 }
197 204
198 close(cache.release)205 close(cache.release)
@@ -200,15 +207,15 @@ func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) {
200 if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil {207 if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil {
201 t.Fatalf("store result = %+v", firstStored)208 t.Fatalf("store result = %+v", firstStored)
202 }209 }
203- secondStored := receiveResult(t, results, "queued cache store")210+ admittedStored := receiveResult(t, results, "admitted cache store")
204- if secondStored.Kind != appports.RemoteJobCacheStore || secondStored.Err != nil {211+ if admittedStored.Kind != appports.RemoteJobCacheStore || admittedStored.Err != nil || admittedStored.Endpoint != admitted {
205- t.Fatalf("queued store result = %+v", secondStored)212+ t.Fatalf("admitted store result = %+v, want the store the lane accepted (%s)", admittedStored, admitted)
206 }213 }
207 cache.mu.Lock()214 cache.mu.Lock()
208 defer cache.mu.Unlock()215 defer cache.mu.Unlock()
209 if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" ||216 if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" ||
210- len(cache.stores[1]) != 1 || cache.stores[1][0].Host != "second" {217+ len(cache.stores[1]) != 1 || cache.stores[1][0].Host != admitted {
211- t.Fatalf("stored payloads = %+v, want serialized first then second", cache.stores)218+ t.Fatalf("stored payloads = %+v, want the blocked store first then %s", cache.stores, admitted)
212 }219 }
213 }220 }
214 221
M internal/adapters/sessionwire/client.go +67 -16
@@ -91,8 +91,6 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
91 return wire.UnmarshalSessions(frame.Payload)91 return wire.UnmarshalSessions(frame.Payload)
92 case wire.MsgCommandResult:92 case wire.MsgCommandResult:
93 return wire.UnmarshalCommandResult(frame.Payload)93 return wire.UnmarshalCommandResult(frame.Payload)
94- case wire.MsgNavigationAction:
95- return wire.UnmarshalNavigationDirective(frame.Payload)
96 case wire.MsgAttachTarget:94 case wire.MsgAttachTarget:
97 return wire.UnmarshalAttachTarget(frame.Payload)95 return wire.UnmarshalAttachTarget(frame.Payload)
98 case wire.MsgRemotePreviewResponse:96 case wire.MsgRemotePreviewResponse:
@@ -111,14 +109,26 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
111 return wire.UnmarshalRouteRetired(frame.Payload)109 return wire.UnmarshalRouteRetired(frame.Payload)
112 case wire.MsgSamePeerSwitchFailure:110 case wire.MsgSamePeerSwitchFailure:
113 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
114- case wire.MsgParkedRouteResponse:
115- return wire.UnmarshalParkedRouteResponse(frame.Payload)
116 case wire.MsgNavigationInventoryResponse:112 case wire.MsgNavigationInventoryResponse:
117 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)113 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
118 case wire.MsgNavigationInventoryDemand:114 case wire.MsgNavigationInventoryDemand:
119 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)115 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
120 case wire.MsgNavigationInventorySelection:116 case wire.MsgNavigationInventorySelection:
121 return wire.UnmarshalNavigationInventorySelection(frame.Payload)117 return wire.UnmarshalNavigationInventorySelection(frame.Payload)
118+ case wire.MsgPickerOffer:
119+ return wire.UnmarshalPickerOffer(frame.Payload)
120+ case wire.MsgPickerSnapshot:
121+ return wire.UnmarshalPickerSnapshot(frame.Payload)
122+ case wire.MsgPickerClosedServer:
123+ return wire.UnmarshalPickerClosed(frame.Payload)
124+ case wire.MsgPickerResult:
125+ return wire.UnmarshalPickerResult(frame.Payload)
126+ case wire.MsgPickerFailure:
127+ return wire.UnmarshalPickerFailure(frame.Payload)
128+ case wire.MsgPickerPreview:
129+ return wire.UnmarshalPickerPreview(frame.Payload)
130+ case wire.MsgPickerControlResponse:
131+ return wire.UnmarshalPickerControlResponse(frame.Payload)
122 case wire.MsgUIReceipt:132 case wire.MsgUIReceipt:
123 return wire.UnmarshalUIReceipt(frame.Payload)133 return wire.UnmarshalUIReceipt(frame.Payload)
124 case wire.MsgUIViewUpdate:134 case wire.MsgUIViewUpdate:
@@ -127,9 +137,10 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
127 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,137 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,
128 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,138 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
129 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,139 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
130- wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,140+ wire.MsgSamePeerSwitchRequest,
131 wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence,141 wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence,
132- wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure:142+ wire.MsgNavigationInventoryRequest, wire.MsgNavigationInventoryPublication, wire.MsgNavigationInventoryFailure,
143+ wire.MsgPickerBegin, wire.MsgPickerCloseClient, wire.MsgPickerSelection, wire.MsgPickerPreviewRequest, wire.MsgPickerControlRequest:
133 return nil, ErrWrongDirection144 return nil, ErrWrongDirection
134 default:145 default:
135 return nil, ErrUnknownMessageType146 return nil, ErrUnknownMessageType
@@ -189,12 +200,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
189 case protocol.SamePeerSwitchRequest:200 case protocol.SamePeerSwitchRequest:
190 payload, err := wire.MarshalSamePeerSwitchRequest(m)201 payload, err := wire.MarshalSamePeerSwitchRequest(m)
191 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: payload}, err202 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: payload}, err
192- case protocol.ParkedRouteRequest:
193- payload := wire.MarshalParkedRouteRequest(m)
194- if payload == nil {
195- return wire.Frame{}, ErrInvalidMessage
196- }
197- return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: payload}, nil
198 case protocol.NavigationInventoryRequest:203 case protocol.NavigationInventoryRequest:
199 payload := wire.MarshalNavigationInventoryRequest(m)204 payload := wire.MarshalNavigationInventoryRequest(m)
200 if payload == nil {205 if payload == nil {
@@ -213,6 +218,36 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
213 return wire.Frame{}, ErrInvalidMessage218 return wire.Frame{}, ErrInvalidMessage
214 }219 }
215 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil220 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: payload}, nil
221+ case protocol.PickerBegin:
222+ payload := wire.MarshalPickerBegin(m)
223+ if payload == nil {
224+ return wire.Frame{}, ErrInvalidMessage
225+ }
226+ return wire.Frame{Type: wire.MsgPickerBegin, Payload: payload}, nil
227+ case protocol.PickerClose:
228+ payload := wire.MarshalPickerClose(m)
229+ if payload == nil {
230+ return wire.Frame{}, ErrInvalidMessage
231+ }
232+ return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: payload}, nil
233+ case protocol.PickerSelection:
234+ payload := wire.MarshalPickerSelection(m)
235+ if payload == nil {
236+ return wire.Frame{}, ErrInvalidMessage
237+ }
238+ return wire.Frame{Type: wire.MsgPickerSelection, Payload: payload}, nil
239+ case protocol.PickerControlRequest:
240+ payload := wire.MarshalPickerControlRequest(m)
241+ if payload == nil {
242+ return wire.Frame{}, ErrInvalidMessage
243+ }
244+ return wire.Frame{Type: wire.MsgPickerControlRequest, Payload: payload}, nil
245+ case protocol.PickerPreviewRequest:
246+ payload := wire.MarshalPickerPreviewRequest(m)
247+ if payload == nil {
248+ return wire.Frame{}, ErrInvalidMessage
249+ }
250+ return wire.Frame{Type: wire.MsgPickerPreviewRequest, Payload: payload}, nil
216 case protocol.RecentRouteSnapshot:251 case protocol.RecentRouteSnapshot:
217 payload, err := wire.MarshalRecentRouteSnapshot(m)252 payload, err := wire.MarshalRecentRouteSnapshot(m)
218 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err253 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: payload}, err
@@ -290,10 +325,6 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
290 if m != nil {325 if m != nil {
291 return encodeClient(*m)326 return encodeClient(*m)
292 }327 }
293- case *protocol.ParkedRouteRequest:
294- if m != nil {
295- return encodeClient(*m)
296- }
297 case *protocol.NavigationInventoryRequest:328 case *protocol.NavigationInventoryRequest:
298 if m != nil {329 if m != nil {
299 return encodeClient(*m)330 return encodeClient(*m)
@@ -306,6 +337,26 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
306 if m != nil {337 if m != nil {
307 return encodeClient(*m)338 return encodeClient(*m)
308 }339 }
340+ case *protocol.PickerBegin:
341+ if m != nil {
342+ return encodeClient(*m)
343+ }
344+ case *protocol.PickerClose:
345+ if m != nil {
346+ return encodeClient(*m)
347+ }
348+ case *protocol.PickerSelection:
349+ if m != nil {
350+ return encodeClient(*m)
351+ }
352+ case *protocol.PickerPreviewRequest:
353+ if m != nil {
354+ return encodeClient(*m)
355+ }
356+ case *protocol.PickerControlRequest:
357+ if m != nil {
358+ return encodeClient(*m)
359+ }
309 case *protocol.RecentRouteSnapshot:360 case *protocol.RecentRouteSnapshot:
310 if m != nil {361 if m != nil {
311 return encodeClient(*m)362 return encodeClient(*m)
M internal/adapters/sessionwire/client_test.go +0 -5
@@ -15,7 +15,6 @@ import (
15 func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {15 func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
16 target := testTarget()16 target := testTarget()
17 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}17 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}
18- lease := protocol.ParkedRouteLeaseID{1}
19 tests := []struct {18 tests := []struct {
20 name string19 name string
21 message protocol.ClientMessage20 message protocol.ClientMessage
@@ -39,7 +38,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
39 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},38 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},
40 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},39 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},
41 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},40 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},
42- {name: "parked", message: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, typeID: wire.MsgParkedRouteRequest},
43 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},41 {name: "snapshot", message: protocol.RecentRouteSnapshot{}, typeID: wire.MsgRecentRouteSnapshot},
44 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},42 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgRouteNavigationFailure},
45 {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},43 {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}, typeID: wire.MsgSessionCreationFailure},
@@ -66,7 +64,6 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
66 64
67 func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {65 func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
68 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}66 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
69- lease := protocol.ParkedRouteLeaseID{1}
70 tests := []struct {67 tests := []struct {
71 name string68 name string
72 message protocol.ServerMessage69 message protocol.ServerMessage
@@ -78,7 +75,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
78 {name: "pong", message: protocol.Pong{}},75 {name: "pong", message: protocol.Pong{}},
79 {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},76 {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},
80 {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},77 {name: "command result", message: protocol.CommandResult{RequestID: 1, OK: true}},
81- {name: "navigation", message: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}},
82 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},78 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
83 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},79 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
84 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},80 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},
@@ -87,7 +83,6 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
87 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},83 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
88 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},84 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},
89 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},85 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},
90- {name: "parked response", message: protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}},
91 }86 }
92 for _, tt := range tests {87 for _, tt := range tests {
93 t.Run(tt.name, func(t *testing.T) {88 t.Run(tt.name, func(t *testing.T) {
A internal/adapters/sessionwire/picker_interaction_test.go +251 -0
@@ -0,0 +1,251 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol"
8+ "github.com/bnema/vev/internal/protocol/wire"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func pickerWireOffer() protocol.PickerOffer {
13+ return protocol.PickerOffer{
14+ InteractionID: 7, RequestID: 3, Intent: protocol.PickerIntentMoveTab,
15+ MoveSourceKey: "ab12/work#tab-1", BarrierEpoch: 3, BarrierState: 9, SizeEpoch: 1,
16+ Title: " Sessions · grouped ",
17+ }
18+}
19+
20+func pickerWireSnapshot() protocol.PickerSnapshot {
21+ return protocol.PickerSnapshot{
22+ InteractionID: 7, SourceID: "serving", SourceRevision: 2, Status: protocol.PickerSourceOK,
23+ Lines: []protocol.PickerLine{
24+ {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true},
25+ {Key: "ab12/work", Kind: protocol.PickerLineSession, Label: "work", Focusable: true, Actions: protocol.PickerCanNavigate},
26+ {Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor", Detail: " (vim)", Attention: true, Focusable: true, Actions: protocol.PickerCanMove},
27+ },
28+ Cursor: protocol.PickerCursor{Key: "ab12/work", Index: 1},
29+ }
30+}
31+
32+func pickerWireSelection() protocol.PickerSelection {
33+ return protocol.PickerSelection{
34+ CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving",
35+ SourceRevision: 2, Key: "ab12/work", Action: protocol.PickerActionMove,
36+ }
37+}
38+
39+func pickerWirePreviewRequest() protocol.PickerPreviewRequest {
40+ return protocol.PickerPreviewRequest{
41+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving",
42+ Key: "ab12/work#tab-1", Width: 80, Height: 24,
43+ }
44+}
45+
46+func pickerWirePreview() protocol.PickerPreview {
47+ return protocol.PickerPreview{
48+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving",
49+ Key: "ab12/work#tab-1", Status: protocol.PickerPreviewOK, Width: 2, Height: 1,
50+ Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}},
51+ }
52+}
53+
54+func pickerWireControlRequest() protocol.PickerControlRequest {
55+ return protocol.PickerControlRequest{
56+ Version: protocol.Version, RequestID: 4, Operation: protocol.PickerControlResolve,
57+ SourceRevision: 2, SourceID: "serving", Key: "ab12/work#tab-1",
58+ }
59+}
60+
61+func pickerWireControlResponse() protocol.PickerControlResponse {
62+ snapshot := protocol.NormalizePickerSnapshot(pickerWireSnapshot())
63+ return protocol.PickerControlResponse{
64+ RequestID: 4, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot,
65+ }
66+}
67+
68+func TestPickerClientMessagesEncodeWithTypes(t *testing.T) {
69+ begin := protocol.PickerBegin{RequestID: 3, Intent: protocol.PickerIntentNavigation}
70+ closeMessage := protocol.PickerClose{InteractionID: 7, RequestID: 3}
71+ selection := pickerWireSelection()
72+ previewRequest := pickerWirePreviewRequest()
73+ controlRequest := pickerWireControlRequest()
74+ tests := []struct {
75+ name string
76+ message protocol.ClientMessage
77+ typeID wire.MsgType
78+ }{
79+ {name: "begin", message: begin, typeID: wire.MsgPickerBegin},
80+ {name: "begin pointer", message: &begin, typeID: wire.MsgPickerBegin},
81+ {name: "close", message: closeMessage, typeID: wire.MsgPickerCloseClient},
82+ {name: "close pointer", message: &closeMessage, typeID: wire.MsgPickerCloseClient},
83+ {name: "selection", message: selection, typeID: wire.MsgPickerSelection},
84+ {name: "selection pointer", message: &selection, typeID: wire.MsgPickerSelection},
85+ {name: "preview request", message: previewRequest, typeID: wire.MsgPickerPreviewRequest},
86+ {name: "preview request pointer", message: &previewRequest, typeID: wire.MsgPickerPreviewRequest},
87+ {name: "control request", message: controlRequest, typeID: wire.MsgPickerControlRequest},
88+ {name: "control request pointer", message: &controlRequest, typeID: wire.MsgPickerControlRequest},
89+ }
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ raw := &scriptedTransport{}
93+ require.NoError(t, NewClientConnection(raw).SendClient(tt.message))
94+ require.Equal(t, tt.typeID, raw.sent.Type)
95+ got, err := decodeClient(raw.sent)
96+ require.NoError(t, err)
97+ switch message := tt.message.(type) {
98+ case *protocol.PickerBegin:
99+ require.Equal(t, *message, got)
100+ case *protocol.PickerClose:
101+ require.Equal(t, *message, got)
102+ case *protocol.PickerSelection:
103+ require.Equal(t, *message, got)
104+ case *protocol.PickerPreviewRequest:
105+ require.Equal(t, *message, got)
106+ case *protocol.PickerControlRequest:
107+ require.Equal(t, *message, got)
108+ default:
109+ require.Equal(t, tt.message, got)
110+ }
111+ })
112+ }
113+}
114+
115+func TestPickerServerMessagesEncodeWithTypes(t *testing.T) {
116+ offer := pickerWireOffer()
117+ snapshot := pickerWireSnapshot()
118+ closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9}
119+ result := protocol.PickerResult{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionKill}
120+ failure := protocol.PickerFailure{CauseActionID: 9, InteractionID: 7, SourceID: "serving", Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget}
121+ preview := pickerWirePreview()
122+ controlResponse := pickerWireControlResponse()
123+ tests := []struct {
124+ name string
125+ message protocol.ServerMessage
126+ typeID wire.MsgType
127+ payload []byte
128+ }{
129+ {name: "offer", message: offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)},
130+ {name: "offer pointer", message: &offer, typeID: wire.MsgPickerOffer, payload: wire.MarshalPickerOffer(offer)},
131+ {name: "snapshot", message: snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)},
132+ {name: "snapshot pointer", message: &snapshot, typeID: wire.MsgPickerSnapshot, payload: wire.MarshalPickerSnapshot(snapshot)},
133+ {name: "closed", message: closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)},
134+ {name: "closed pointer", message: &closed, typeID: wire.MsgPickerClosedServer, payload: wire.MarshalPickerClosed(closed)},
135+ {name: "result", message: result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)},
136+ {name: "result pointer", message: &result, typeID: wire.MsgPickerResult, payload: wire.MarshalPickerResult(result)},
137+ {name: "failure", message: failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)},
138+ {name: "failure pointer", message: &failure, typeID: wire.MsgPickerFailure, payload: wire.MarshalPickerFailure(failure)},
139+ {name: "preview", message: preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)},
140+ {name: "preview pointer", message: &preview, typeID: wire.MsgPickerPreview, payload: wire.MarshalPickerPreview(preview)},
141+ {name: "control response", message: controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)},
142+ {name: "control response pointer", message: &controlResponse, typeID: wire.MsgPickerControlResponse, payload: wire.MarshalPickerControlResponse(controlResponse)},
143+ }
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ require.NotNil(t, tt.payload)
147+ raw := &scriptedTransport{}
148+ require.NoError(t, NewServerConnection(raw).SendServer(tt.message))
149+ require.Equal(t, tt.typeID, raw.sent.Type)
150+ require.Equal(t, tt.payload, raw.sent.Payload)
151+ })
152+ }
153+}
154+
155+func TestPickerControlMessagesRoundTripThroughConnections(t *testing.T) {
156+ request := pickerWireControlRequest()
157+ response := pickerWireControlResponse()
158+
159+ for _, tc := range []struct {
160+ name string
161+ message protocol.ClientMessage
162+ }{
163+ {name: "request", message: request},
164+ {name: "request pointer", message: &request},
165+ } {
166+ t.Run(tc.name, func(t *testing.T) {
167+ raw := &scriptedTransport{}
168+ require.NoError(t, NewClientConnection(raw).SendClient(tc.message))
169+ require.Equal(t, wire.MsgPickerControlRequest, raw.sent.Type)
170+ got, err := NewServerConnection(&scriptedTransport{recv: raw.sent}).ReceiveClient()
171+ require.NoError(t, err)
172+ require.Equal(t, request, got)
173+ })
174+ }
175+
176+ for _, tc := range []struct {
177+ name string
178+ message protocol.ServerMessage
179+ }{
180+ {name: "response", message: response},
181+ {name: "response pointer", message: &response},
182+ } {
183+ t.Run(tc.name, func(t *testing.T) {
184+ raw := &scriptedTransport{}
185+ require.NoError(t, NewServerConnection(raw).SendServer(tc.message))
186+ require.Equal(t, wire.MsgPickerControlResponse, raw.sent.Type)
187+ got, err := NewClientConnection(&scriptedTransport{recv: raw.sent}).ReceiveServer()
188+ require.NoError(t, err)
189+ require.Equal(t, response, got)
190+ })
191+ }
192+}
193+
194+func TestPickerControlRequestMalformedPreHandshakeClassification(t *testing.T) {
195+ tests := []struct {
196+ name string
197+ payload []byte
198+ version uint16
199+ }{
200+ {name: "truncated version", payload: []byte{0}},
201+ {name: "mismatched version", payload: []byte{0, 60}, version: 60},
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ _, err := NewServerConnection(&scriptedTransport{recv: wire.Frame{Type: wire.MsgPickerControlRequest, Payload: tt.payload}}).ReceiveClient()
206+ var failure *protocol.DecodeFailure
207+ require.ErrorAs(t, err, &failure)
208+ require.Equal(t, protocol.DecodeMalformed, failure.Category)
209+ require.Equal(t, uint8(wire.MsgPickerControlRequest), failure.Type)
210+ require.Equal(t, tt.version, failure.Version)
211+ })
212+ }
213+}
214+
215+func TestPickerMessagesRejectWrongDirection(t *testing.T) {
216+ offer := pickerWireOffer()
217+ snapshot := pickerWireSnapshot()
218+ selection := pickerWireSelection()
219+ clientFrames := []wire.Frame{
220+ {Type: wire.MsgPickerBegin, Payload: wire.MarshalPickerBegin(protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation})},
221+ {Type: wire.MsgPickerCloseClient, Payload: wire.MarshalPickerClose(protocol.PickerClose{InteractionID: 1})},
222+ {Type: wire.MsgPickerSelection, Payload: wire.MarshalPickerSelection(selection)},
223+ {Type: wire.MsgPickerPreviewRequest, Payload: wire.MarshalPickerPreviewRequest(pickerWirePreviewRequest())},
224+ {Type: wire.MsgPickerControlRequest, Payload: wire.MarshalPickerControlRequest(pickerWireControlRequest())},
225+ }
226+ serverFrames := []wire.Frame{
227+ {Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)},
228+ {Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)},
229+ {Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: 1})},
230+ {Type: wire.MsgPickerResult, Payload: wire.MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill})},
231+ {Type: wire.MsgPickerFailure, Payload: wire.MarshalPickerFailure(protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey})},
232+ {Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(pickerWirePreview())},
233+ {Type: wire.MsgPickerControlResponse, Payload: wire.MarshalPickerControlResponse(pickerWireControlResponse())},
234+ }
235+ for _, frame := range clientFrames {
236+ require.NotNil(t, frame.Payload)
237+ got, err := decodeClient(frame)
238+ require.NoError(t, err)
239+ require.NotNil(t, got)
240+ _, err = decodeServer(frame)
241+ require.ErrorIs(t, err, ErrWrongDirection)
242+ }
243+ for _, frame := range serverFrames {
244+ require.NotNil(t, frame.Payload)
245+ got, err := decodeServer(frame)
246+ require.NoError(t, err)
247+ require.NotNil(t, got)
248+ _, err = decodeClient(frame)
249+ require.ErrorIs(t, err, ErrWrongDirection)
250+ }
251+}
M internal/adapters/sessionwire/server.go +87 -26
@@ -59,6 +59,8 @@ func (c *serverConnection) ReceiveClient() (protocol.ClientMessage, error) {
59 failure.Kind = protocol.DecodeMessageNavigationInventory59 failure.Kind = protocol.DecodeMessageNavigationInventory
60 failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload)60 failure.Version, _ = wire.PeekNavigationInventoryVersion(frame.Payload)
61 failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload)61 failure.RequestID, failure.HasRequestID = wire.PeekNavigationInventoryRequestID(frame.Payload)
62+ case wire.MsgPickerControlRequest:
63+ failure.Version, _ = wire.PeekPickerControlVersion(frame.Payload)
62 }64 }
63 return nil, failure65 return nil, failure
64 }66 }
@@ -230,14 +232,22 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
230 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)232 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)
231 case wire.MsgSamePeerSwitchRequest:233 case wire.MsgSamePeerSwitchRequest:
232 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)234 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
233- case wire.MsgParkedRouteRequest:
234- return wire.UnmarshalParkedRouteRequest(frame.Payload)
235 case wire.MsgNavigationInventoryRequest:235 case wire.MsgNavigationInventoryRequest:
236 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)236 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
237 case wire.MsgNavigationInventoryPublication:237 case wire.MsgNavigationInventoryPublication:
238 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)238 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
239 case wire.MsgNavigationInventoryFailure:239 case wire.MsgNavigationInventoryFailure:
240 return wire.UnmarshalNavigationInventoryFailure(frame.Payload)240 return wire.UnmarshalNavigationInventoryFailure(frame.Payload)
241+ case wire.MsgPickerBegin:
242+ return wire.UnmarshalPickerBegin(frame.Payload)
243+ case wire.MsgPickerCloseClient:
244+ return wire.UnmarshalPickerClose(frame.Payload)
245+ case wire.MsgPickerSelection:
246+ return wire.UnmarshalPickerSelection(frame.Payload)
247+ case wire.MsgPickerPreviewRequest:
248+ return wire.UnmarshalPickerPreviewRequest(frame.Payload)
249+ case wire.MsgPickerControlRequest:
250+ return wire.UnmarshalPickerControlRequest(frame.Payload)
241 case wire.MsgRecentRouteSnapshot:251 case wire.MsgRecentRouteSnapshot:
242 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)252 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
243 case wire.MsgRouteNavigationFailure:253 case wire.MsgRouteNavigationFailure:
@@ -247,12 +257,13 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
247 case wire.MsgUIFence:257 case wire.MsgUIFence:
248 return wire.UnmarshalUIFence(frame.Payload)258 return wire.UnmarshalUIFence(frame.Payload)
249 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,259 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,
250- wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,260+ wire.MsgSessions, wire.MsgCommandResult, wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
251- wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
252 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,261 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
253- wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse,262+ wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure,
254 wire.MsgUIReceipt, wire.MsgUIViewUpdate, wire.MsgNavigationInventoryResponse,263 wire.MsgUIReceipt, wire.MsgUIViewUpdate, wire.MsgNavigationInventoryResponse,
255- wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection, wire.MsgRouteRetired:264+ wire.MsgNavigationInventoryDemand, wire.MsgNavigationInventorySelection, wire.MsgRouteRetired,
265+ wire.MsgPickerOffer, wire.MsgPickerSnapshot, wire.MsgPickerClosedServer, wire.MsgPickerResult, wire.MsgPickerFailure,
266+ wire.MsgPickerPreview, wire.MsgPickerControlResponse:
256 return nil, ErrWrongDirection267 return nil, ErrWrongDirection
257 default:268 default:
258 return nil, ErrUnknownMessageType269 return nil, ErrUnknownMessageType
@@ -289,12 +300,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
289 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil300 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil
290 case protocol.CommandResult:301 case protocol.CommandResult:
291 return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil302 return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil
292- case protocol.NavigationDirective:
293- payload := wire.MarshalNavigationDirective(m)
294- if payload == nil {
295- return wire.Frame{}, ErrInvalidMessage
296- }
297- return wire.Frame{Type: wire.MsgNavigationAction, Payload: payload}, nil
298 case protocol.AttachTarget:303 case protocol.AttachTarget:
299 payload := wire.MarshalAttachTarget(m)304 payload := wire.MarshalAttachTarget(m)
300 if payload == nil {305 if payload == nil {
@@ -328,12 +333,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
328 case protocol.SamePeerSwitchFailure:333 case protocol.SamePeerSwitchFailure:
329 payload, err := wire.MarshalSamePeerSwitchFailure(m)334 payload, err := wire.MarshalSamePeerSwitchFailure(m)
330 return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err335 return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err
331- case protocol.ParkedRouteResponse:
332- payload := wire.MarshalParkedRouteResponse(m)
333- if payload == nil {
334- return wire.Frame{}, ErrInvalidMessage
335- }
336- return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: payload}, nil
337 case protocol.NavigationInventoryResponse:336 case protocol.NavigationInventoryResponse:
338 payload := wire.MarshalNavigationInventoryResponse(m)337 payload := wire.MarshalNavigationInventoryResponse(m)
339 if payload == nil {338 if payload == nil {
@@ -352,6 +351,48 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
352 return wire.Frame{}, ErrInvalidMessage351 return wire.Frame{}, ErrInvalidMessage
353 }352 }
354 return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil353 return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil
354+ case protocol.PickerOffer:
355+ payload := wire.MarshalPickerOffer(m)
356+ if payload == nil {
357+ return wire.Frame{}, ErrInvalidMessage
358+ }
359+ return wire.Frame{Type: wire.MsgPickerOffer, Payload: payload}, nil
360+ case protocol.PickerSnapshot:
361+ payload := wire.MarshalPickerSnapshot(m)
362+ if payload == nil {
363+ return wire.Frame{}, ErrInvalidMessage
364+ }
365+ return wire.Frame{Type: wire.MsgPickerSnapshot, Payload: payload}, nil
366+ case protocol.PickerClosed:
367+ payload := wire.MarshalPickerClosed(m)
368+ if payload == nil {
369+ return wire.Frame{}, ErrInvalidMessage
370+ }
371+ return wire.Frame{Type: wire.MsgPickerClosedServer, Payload: payload}, nil
372+ case protocol.PickerResult:
373+ payload := wire.MarshalPickerResult(m)
374+ if payload == nil {
375+ return wire.Frame{}, ErrInvalidMessage
376+ }
377+ return wire.Frame{Type: wire.MsgPickerResult, Payload: payload}, nil
378+ case protocol.PickerFailure:
379+ payload := wire.MarshalPickerFailure(m)
380+ if payload == nil {
381+ return wire.Frame{}, ErrInvalidMessage
382+ }
383+ return wire.Frame{Type: wire.MsgPickerFailure, Payload: payload}, nil
384+ case protocol.PickerControlResponse:
385+ payload := wire.MarshalPickerControlResponse(m)
386+ if payload == nil {
387+ return wire.Frame{}, ErrInvalidMessage
388+ }
389+ return wire.Frame{Type: wire.MsgPickerControlResponse, Payload: payload}, nil
390+ case protocol.PickerPreview:
391+ payload := wire.MarshalPickerPreview(m)
392+ if payload == nil {
393+ return wire.Frame{}, ErrInvalidMessage
394+ }
395+ return wire.Frame{Type: wire.MsgPickerPreview, Payload: payload}, nil
355 case *protocol.Welcome:396 case *protocol.Welcome:
356 if m != nil {397 if m != nil {
357 return encodeServer(*m)398 return encodeServer(*m)
@@ -388,10 +429,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
388 if m != nil {429 if m != nil {
389 return encodeServer(*m)430 return encodeServer(*m)
390 }431 }
391- case *protocol.NavigationDirective:
392- if m != nil {
393- return encodeServer(*m)
394- }
395 case *protocol.AttachTarget:432 case *protocol.AttachTarget:
396 if m != nil {433 if m != nil {
397 return encodeServer(*m)434 return encodeServer(*m)
@@ -428,10 +465,6 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
428 if m != nil {465 if m != nil {
429 return encodeServer(*m)466 return encodeServer(*m)
430 }467 }
431- case *protocol.ParkedRouteResponse:
432- if m != nil {
433- return encodeServer(*m)
434- }
435 case *protocol.NavigationInventoryResponse:468 case *protocol.NavigationInventoryResponse:
436 if m != nil {469 if m != nil {
437 return encodeServer(*m)470 return encodeServer(*m)
@@ -444,6 +477,34 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
444 if m != nil {477 if m != nil {
445 return encodeServer(*m)478 return encodeServer(*m)
446 }479 }
480+ case *protocol.PickerOffer:
481+ if m != nil {
482+ return encodeServer(*m)
483+ }
484+ case *protocol.PickerSnapshot:
485+ if m != nil {
486+ return encodeServer(*m)
487+ }
488+ case *protocol.PickerClosed:
489+ if m != nil {
490+ return encodeServer(*m)
491+ }
492+ case *protocol.PickerResult:
493+ if m != nil {
494+ return encodeServer(*m)
495+ }
496+ case *protocol.PickerFailure:
497+ if m != nil {
498+ return encodeServer(*m)
499+ }
500+ case *protocol.PickerPreview:
501+ if m != nil {
502+ return encodeServer(*m)
503+ }
504+ case *protocol.PickerControlResponse:
505+ if m != nil {
506+ return encodeServer(*m)
507+ }
447 default:508 default:
448 return wire.Frame{}, ErrWrongDirection509 return wire.Frame{}, ErrWrongDirection
449 }510 }
M internal/adapters/sessionwire/server_test.go +0 -7
@@ -54,7 +54,6 @@ func testTarget() domain.RemoteSessionTarget {
54 func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {54 func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
55 target := testTarget()55 target := testTarget()
56 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}56 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}
57- lease := protocol.ParkedRouteLeaseID{1}
58 commandPayload, err := wire.MarshalCommandRequest(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"})57 commandPayload, err := wire.MarshalCommandRequest(protocol.CommandRequest{Version: protocol.Version, RequestID: 7, Slug: "list-sessions"})
59 require.NoError(t, err)58 require.NoError(t, err)
60 resizePayload, err := wire.MarshalResize(protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}})59 resizePayload, err := wire.MarshalResize(protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}})
@@ -93,7 +92,6 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
93 {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}},92 {name: "preview", frame: wire.Frame{Type: wire.MsgRemotePreviewRequest, Payload: wire.MarshalRemotePreviewRequest(protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1})}, want: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}},
94 {name: "attention", frame: wire.Frame{Type: wire.MsgRouteAttentionSubscription, Payload: attentionPayload}, want: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}},93 {name: "attention", frame: wire.Frame{Type: wire.MsgRouteAttentionSubscription, Payload: attentionPayload}, want: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}},
95 {name: "same peer", frame: wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: switchPayload}, want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},94 {name: "same peer", frame: wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: switchPayload}, want: protocol.SamePeerSwitchRequest{RequestID: 3, Target: exact}},
96- {name: "parked", frame: wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare})}, want: protocol.ParkedRouteRequest{RequestID: 4, LeaseID: lease, Action: protocol.ParkedRoutePrepare}},
97 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},95 {name: "snapshot", frame: wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: snapshotPayload}, want: protocol.RecentRouteSnapshot{}},
98 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},96 {name: "route failure", frame: wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: failurePayload}, want: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
99 {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},97 {name: "creation failure", frame: wire.Frame{Type: wire.MsgSessionCreationFailure, Payload: creationFailurePayload}, want: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
@@ -110,7 +108,6 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
110 108
111 func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {109 func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
112 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}110 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
113- lease := protocol.ParkedRouteLeaseID{1}
114 welcome := protocol.Welcome{SessionID: "session"}111 welcome := protocol.Welcome{SessionID: "session"}
115 errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}112 errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}
116 output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}113 output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}
@@ -120,7 +117,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
120 pong := protocol.Pong{}117 pong := protocol.Pong{}
121 sessions := protocol.Sessions{}118 sessions := protocol.Sessions{}
122 commandResult := protocol.CommandResult{RequestID: 1, OK: true}119 commandResult := protocol.CommandResult{RequestID: 1, OK: true}
123- navigation := protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}
124 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}120 attachTarget := protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}
125 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}121 preview := protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}
126 identity := protocol.CommittedRouteIdentity{Target: exact}122 identity := protocol.CommittedRouteIdentity{Target: exact}
@@ -129,7 +125,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
129 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}125 routeFailure := protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}
130 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}126 routePosition := protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}
131 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}127 switchFailure := protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}
132- parkedResponse := protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}
133 128
134 outputPayload, err := wire.MarshalOutput(output)129 outputPayload, err := wire.MarshalOutput(output)
135 require.NoError(t, err)130 require.NoError(t, err)
@@ -167,7 +162,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
167 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},162 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},
168 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},163 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},
169 {name: "command result", message: commandResult, typeID: wire.MsgCommandResult, payload: wire.MarshalCommandResult(commandResult)},164 {name: "command result", message: commandResult, typeID: wire.MsgCommandResult, payload: wire.MarshalCommandResult(commandResult)},
170- {name: "navigation", message: navigation, typeID: wire.MsgNavigationAction, payload: wire.MarshalNavigationDirective(navigation)},
171 {name: "attach target", message: attachTarget, typeID: wire.MsgAttachTarget, payload: wire.MarshalAttachTarget(attachTarget)},165 {name: "attach target", message: attachTarget, typeID: wire.MsgAttachTarget, payload: wire.MarshalAttachTarget(attachTarget)},
172 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},166 {name: "preview", message: preview, typeID: wire.MsgRemotePreviewResponse, payload: wire.MarshalRemotePreview(preview)},
173 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},167 {name: "identity", message: identity, typeID: wire.MsgCommittedRouteIdentity, payload: identityPayload},
@@ -177,7 +171,6 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
177 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},171 {name: "route failure", message: routeFailure, typeID: wire.MsgRouteNavigationFailure, payload: routeFailurePayload},
178 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},172 {name: "route position", message: routePosition, typeID: wire.MsgRoutePosition, payload: routePositionPayload},
179 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},173 {name: "switch failure", message: switchFailure, typeID: wire.MsgSamePeerSwitchFailure, payload: switchFailurePayload},
180- {name: "parked response", message: parkedResponse, typeID: wire.MsgParkedRouteResponse, payload: wire.MarshalParkedRouteResponse(parkedResponse)},
181 }174 }
182 for _, tt := range tests {175 for _, tt := range tests {
183 t.Run(tt.name, func(t *testing.T) {176 t.Run(tt.name, func(t *testing.T) {
M internal/adapters/sessionwire/ui_test.go +0 -1
@@ -50,7 +50,6 @@ func TestUISynchronizationServerDispatch(t *testing.T) {
50 {"receipt", protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 1, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}},50 {"receipt", protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 1, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed}},
51 {"view update", protocol.UIViewUpdate{Epoch: 2, State: 1, Context: context}},51 {"view update", protocol.UIViewUpdate{Epoch: 2, State: 1, Context: context}},
52 {"attach target", protocol.AttachTarget{CauseActionID: 7, Session: "work", Intent: protocol.IntentAttach}},52 {"attach target", protocol.AttachTarget{CauseActionID: 7, Session: "work", Intent: protocol.IntentAttach}},
53- {"navigation", protocol.NavigationDirective{CauseActionID: 7, Action: protocol.NavigationBack}},
54 {"route action", protocol.RouteNavigationAction{CauseActionID: 7, SnapshotGeneration: 1, Key: 2, Generation: 3}},53 {"route action", protocol.RouteNavigationAction{CauseActionID: 7, SnapshotGeneration: 1, Key: 2, Generation: 3}},
55 {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}},54 {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}},
56 } {55 } {
M internal/adapters/uidriver/socket_test.go +7 -1
@@ -191,9 +191,15 @@ func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) {
191 require.Zero(t, ready.ID)191 require.Zero(t, ready.ID)
192 }192 }
193 193
194+// shortDir returns a private directory whose absolute path stays inside the
195+// unix socket length limit. It is created under the system temporary
196+// directory: the checkout's own path can already be long enough to overflow
197+// the limit in a nested worktree.
194 func shortDir(t *testing.T) string {198 func shortDir(t *testing.T) string {
195 t.Helper()199 t.Helper()
196- directory, err := os.MkdirTemp(".", "v")200+ base, err := filepath.EvalSymlinks(os.TempDir())
201+ require.NoError(t, err)
202+ directory, err := os.MkdirTemp(base, "vev-ui")
197 require.NoError(t, err)203 require.NoError(t, err)
198 directory, err = filepath.Abs(directory)204 directory, err = filepath.Abs(directory)
199 require.NoError(t, err)205 require.NoError(t, err)
A internal/app/client_hosts.go +91 -0
@@ -0,0 +1,91 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+
9+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
10+ "github.com/bnema/vev/internal/adapters/sessionwire"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/remotes"
14+)
15+
16+// This file composes the endpoint bindings a launching client reuses across
17+// handoffs. Remote discovery and catalogue publication remain daemon-owned.
18+
19+// clientEndpointFactory resolves one endpoint into the carriage the client
20+// reuses for every session on that endpoint. Transport mode, launch
21+// configuration, and endpoint environment are frozen here by the composition
22+// root; the client only receives the resulting typed dialer.
23+type clientEndpointFactory struct {
24+ factory remoteDialerForTarget
25+ mode remoteadapter.TransportMode
26+ // modeErr is the transport-mode validation failure, reported for every
27+ // endpoint so an invalid configured mode fails a handoff instead of
28+ // silently selecting another carriage.
29+ modeErr error
30+ // allowlistErr is the launch-allowlist validation failure, reported for
31+ // every endpoint so a malformed configured allowlist fails remote
32+ // resolution instead of admitting an unvetted endpoint. It is deliberately
33+ // not fatal to construction: a local attach never resolves an endpoint and
34+ // must keep working with a malformed allowlist in the environment.
35+ allowlistErr error
36+ environment func(string) []string
37+ allowed map[string]struct{}
38+ // restricted reports whether an allowlist was configured at all. A
39+ // configured allowlist is authoritative even when it lists nothing.
40+ restricted bool
41+ log *slog.Logger
42+}
43+
44+var _ ports.RemoteEndpointFactory = clientEndpointFactory{}
45+
46+func (f clientEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
47+ if err := ctx.Err(); err != nil {
48+ return ports.RemoteEndpointBinding{}, err
49+ }
50+ if err := domain.ValidateRemoteHostTarget(endpoint); err != nil {
51+ return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: invalid remote endpoint: %w", err)
52+ }
53+ if f.modeErr != nil {
54+ return ports.RemoteEndpointBinding{}, f.modeErr
55+ }
56+ if f.allowlistErr != nil {
57+ return ports.RemoteEndpointBinding{}, f.allowlistErr
58+ }
59+ if f.restricted && !allowlistedRemoteEndpoint(f.allowed, endpoint) {
60+ return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: remote endpoint %q is not allowed", endpoint)
61+ }
62+ if f.factory == nil {
63+ return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory unavailable")
64+ }
65+ // The carriage is endpoint-scoped: the session name only labels logs, so a
66+ // binding resolved for one session serves every later session on it.
67+ dialer, err := f.factory(endpoint, "", f.mode, f.log)
68+ if err != nil {
69+ return ports.RemoteEndpointBinding{}, err
70+ }
71+ if dialer == nil {
72+ return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory returned no dialer")
73+ }
74+ return ports.RemoteEndpointBinding{
75+ Dialer: sessionwire.NewClientDialer(dialer),
76+ Environment: clientEnvironment(f.environment, endpoint),
77+ }, nil
78+}
79+
80+// newClientHostRegistry composes the runner-scoped endpoint cache. A malformed
81+// launch allowlist is stored on the factory and deferred to ResolveEndpoint, so
82+// it can only fail a remote target's resolution, never construction or a local
83+// attach. Without a configured allowlist every endpoint is admitted.
84+func newClientHostRegistry(deps runAttachDeps, mode remoteadapter.TransportMode, modeErr error, log *slog.Logger) ports.ClientHostRegistry {
85+ allowed, restricted, allowlistErr := remoteLaunchAllowlistFromEnv()
86+ factory := clientEndpointFactory{
87+ factory: deps.remoteDialerFactory, mode: mode, modeErr: modeErr, allowlistErr: allowlistErr,
88+ environment: deps.remoteEnvironment, allowed: allowed, restricted: restricted, log: log,
89+ }
90+ return remotes.NewHostRegistry(factory)
91+}
A internal/app/client_hosts_test.go +262 -0
@@ -0,0 +1,262 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+type clientHostsTestDialer struct{ name string }
21+
22+func (d clientHostsTestDialer) Dial(context.Context) (wire.Transport, error) {
23+ return nil, errors.New("client hosts test dialer is never dialed")
24+}
25+
26+var _ wire.Dialer = clientHostsTestDialer{}
27+
28+func clientHostsEndpointFactory(factory remoteDialerForTarget, allowed map[string]struct{}, restricted bool, environment func(string) []string) clientEndpointFactory {
29+ return clientEndpointFactory{
30+ factory: factory, mode: remoteadapter.TransportUDP, environment: environment,
31+ allowed: allowed, restricted: restricted, log: slog.New(slog.DiscardHandler),
32+ }
33+}
34+
35+func TestClientEndpointFactoryResolvesOneCarriagePerEndpoint(t *testing.T) {
36+ factory := newRemoteDialerFactoryMock(t)
37+ factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{name: "arch"}, nil).Once()
38+ factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{name: "cache"}, nil).Once()
39+
40+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string {
41+ return []string{"ENDPOINT=" + endpoint}
42+ })
43+ first, err := resolver.ResolveEndpoint(context.Background(), "arch")
44+ require.NoError(t, err)
45+ require.NotNil(t, first.Dialer)
46+ require.Equal(t, []string{"ENDPOINT=arch"}, first.Environment)
47+
48+ second, err := resolver.ResolveEndpoint(context.Background(), "cache")
49+ require.NoError(t, err)
50+ require.Equal(t, []string{"ENDPOINT=cache"}, second.Environment)
51+}
52+
53+func TestClientEndpointFactoryRefusesEndpointsOutsideTheAllowlist(t *testing.T) {
54+ factory := newRemoteDialerFactoryMock(t)
55+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{"arch": {}}, true, nil)
56+
57+ _, err := resolver.ResolveEndpoint(context.Background(), "other")
58+ require.ErrorContains(t, err, "not allowed")
59+
60+ // A configured allowlist that lists nothing denies every endpoint, and the
61+ // refusal never reaches the dialer factory.
62+ resolver = clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{}, true, nil)
63+ _, err = resolver.ResolveEndpoint(context.Background(), "arch")
64+ require.ErrorContains(t, err, "not allowed")
65+
66+ // Without a configured allowlist every valid endpoint resolves.
67+ factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once()
68+ resolver = clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
69+ _, err = resolver.ResolveEndpoint(context.Background(), "arch")
70+ require.NoError(t, err)
71+}
72+
73+func TestClientEndpointFactoryRejectsInvalidEndpoints(t *testing.T) {
74+ factory := newRemoteDialerFactoryMock(t)
75+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
76+ for _, endpoint := range []string{"", "bad\x1bhost", "spaces are not a host"} {
77+ _, err := resolver.ResolveEndpoint(context.Background(), endpoint)
78+ require.Error(t, err, "endpoint %q must be refused before a carriage is built", endpoint)
79+ }
80+}
81+
82+func TestClientEndpointFactoryKeepsFactoryErrorsAuthoritative(t *testing.T) {
83+ factory := newRemoteDialerFactoryMock(t)
84+ want := errors.New("transport mode refused this endpoint")
85+ factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(nil, want).Once()
86+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
87+
88+ _, err := resolver.ResolveEndpoint(context.Background(), "arch")
89+ require.ErrorIs(t, err, want)
90+}
91+
92+func TestClientEndpointFactoryHonoursContextCancellation(t *testing.T) {
93+ factory := newRemoteDialerFactoryMock(t)
94+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
95+ ctx, cancel := context.WithCancel(context.Background())
96+ cancel()
97+ _, err := resolver.ResolveEndpoint(ctx, "arch")
98+ require.ErrorIs(t, err, context.Canceled)
99+}
100+
101+// TestClientHostRegistryDefersMalformedLaunchAllowlist pins that a malformed
102+// configured allowlist is stored on the endpoint factory instead of failing
103+// registry construction: a local attach never resolves a remote endpoint, so
104+// construction and local attach keep working, while remote resolution stays
105+// fail-closed.
106+func TestClientHostRegistryDefersMalformedLaunchAllowlist(t *testing.T) {
107+ t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host")
108+ factory := newRemoteDialerFactoryMock(t)
109+
110+ registry := newClientHostRegistry(runAttachDeps{remoteDialerFactory: factory.DialerForRemote}, remoteadapter.TransportUDP, nil, slog.New(slog.DiscardHandler))
111+ require.NotNil(t, registry, "a malformed allowlist must not fail registry construction")
112+
113+ _, err := registry.ResolveEndpoint(context.Background(), "arch")
114+ require.ErrorContains(t, err, "invalid configured remote endpoint allowlist")
115+}
116+
117+// TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist pins the
118+// local attach path end to end: a malformed remote allowlist in the
119+// environment is deferred, so a local session still attaches.
120+func TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist(t *testing.T) {
121+ t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host")
122+ factory := newRemoteDialerFactoryMock(t)
123+ attached := false
124+ err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
125+ localDialer: func() wire.Dialer { return clientHostsTestDialer{name: "local"} },
126+ remoteDialerFactory: factory.DialerForRemote,
127+ runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
128+ require.False(t, request.Remote)
129+ attached = true
130+ return nil
131+ },
132+ })
133+ require.NoError(t, err, "a malformed remote allowlist must not break local attach")
134+ require.True(t, attached)
135+}
136+
137+func TestClientEndpointFactoryKeepsNilEnvironmentDistinct(t *testing.T) {
138+ factory := newRemoteDialerFactoryMock(t)
139+ factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once()
140+ factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportUDP, nil).Return(clientHostsTestDialer{}, nil).Once()
141+ resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string {
142+ if endpoint == "cache" {
143+ return []string{}
144+ }
145+ return nil
146+ })
147+
148+ absent, err := resolver.ResolveEndpoint(context.Background(), "arch")
149+ require.NoError(t, err)
150+ require.Nil(t, absent.Environment)
151+
152+ empty, err := resolver.ResolveEndpoint(context.Background(), "cache")
153+ require.NoError(t, err)
154+ require.NotNil(t, empty.Environment)
155+ require.Empty(t, empty.Environment)
156+}
157+
158+// memorySeedCache is a durable-cache stand-in that records writes, so a test
159+// can prove the client monitor never writes the daemon's snapshot file.
160+type memorySeedCache struct {
161+ mu sync.Mutex
162+ entries []catalogue.RemoteCatalogCacheEntry
163+ loads int
164+ stores int
165+ loadErr error
166+}
167+
168+func (c *memorySeedCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
169+ c.mu.Lock()
170+ defer c.mu.Unlock()
171+ c.loads++
172+ if c.loadErr != nil {
173+ return nil, c.loadErr
174+ }
175+ return append([]catalogue.RemoteCatalogCacheEntry(nil), c.entries...), nil
176+}
177+
178+func (c *memorySeedCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
179+ c.mu.Lock()
180+ defer c.mu.Unlock()
181+ c.stores++
182+ c.entries = append([]catalogue.RemoteCatalogCacheEntry(nil), entries...)
183+ return nil
184+}
185+
186+func (c *memorySeedCache) writeCount() int {
187+ c.mu.Lock()
188+ defer c.mu.Unlock()
189+ return c.stores
190+}
191+
192+func (c *memorySeedCache) loadCount() int {
193+ c.mu.Lock()
194+ defer c.mu.Unlock()
195+ return c.loads
196+}
197+
198+var _ ports.RemoteCatalogCache = (*memorySeedCache)(nil)
199+
200+// TestSeededCatalogCacheNeverWritesTheDurableSnapshot pins the client cache
201+// contract: it seeds from the durable entries once and keeps every later write
202+// in its own memory, so a client monitor cannot race the daemon's writer.
203+func TestSeededCatalogCacheNeverWritesTheDurableSnapshot(t *testing.T) {
204+ seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{Host: "arch"}}}
205+ client := remoteadapter.NewSeededCatalogCache(seed)
206+
207+ seeded, err := client.Load()
208+ require.NoError(t, err)
209+ require.Len(t, seeded, 1)
210+ require.Equal(t, "arch", seeded[0].Host)
211+ require.Equal(t, 1, seed.loadCount(), "the durable cache is read once")
212+
213+ require.NoError(t, client.Store([]catalogue.RemoteCatalogCacheEntry{{Host: "cache"}}))
214+ require.Zero(t, seed.writeCount(), "the client cache must never write the durable snapshot")
215+ require.Equal(t, 1, seed.loadCount(), "a write suppresses the seed read")
216+
217+ stored, err := client.Load()
218+ require.NoError(t, err)
219+ require.Equal(t, "cache", stored[0].Host)
220+}
221+
222+func TestSeededCatalogCacheCopiesEntriesDefensively(t *testing.T) {
223+ seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{
224+ Host: "arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}}},
225+ }}}
226+ client := remoteadapter.NewSeededCatalogCache(seed)
227+
228+ first, err := client.Load()
229+ require.NoError(t, err)
230+ first[0].Sessions[0].Tabs[0].ID = "mutated"
231+ first[0].Host = "mutated"
232+
233+ second, err := client.Load()
234+ require.NoError(t, err)
235+ require.Equal(t, "arch", second[0].Host)
236+ require.Equal(t, "tab-1", second[0].Sessions[0].Tabs[0].ID)
237+
238+ seed.mu.Lock()
239+ seed.entries[0].Host = "mutated"
240+ seed.mu.Unlock()
241+ third, err := client.Load()
242+ require.NoError(t, err)
243+ require.Equal(t, "arch", third[0].Host, "the client cache is not a live view of the durable file")
244+}
245+
246+func TestSeededCatalogCacheReportsASeedFailureOnce(t *testing.T) {
247+ want := errors.New("durable cache is unreadable")
248+ seed := &memorySeedCache{loadErr: want}
249+ client := remoteadapter.NewSeededCatalogCache(seed)
250+
251+ _, err := client.Load()
252+ require.ErrorIs(t, err, want)
253+ _, err = client.Load()
254+ require.ErrorIs(t, err, want)
255+ require.Equal(t, 1, seed.loadCount(), "a broken seed is reported, never retried")
256+
257+ var nilCache *remoteadapter.SeededCatalogCache
258+ entries, err := nilCache.Load()
259+ require.NoError(t, err)
260+ require.Empty(t, entries)
261+ require.NoError(t, nilCache.Store(nil))
262+}
M internal/app/dial_only_test.go +21 -0
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 "time"6 "time"
7+
8+ "github.com/stretchr/testify/require"
7 )9 )
8 10
9 // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the11 // TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the
@@ -28,3 +30,22 @@ func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) {
28 t.Fatal("dial-only control dial without observer must also fail fast")30 t.Fatal("dial-only control dial without observer must also fail fast")
29 }31 }
30 }32 }
33+
34+// TestHybridMissingLocalSourceNeverStartsDaemon pins the hybrid degradation
35+// contract: when the dial-only local inventory source is unreachable, the
36+// composition must surface the dial failure rather than starting a daemon or
37+// creating a local attachment. The missing source degrades that source only.
38+func TestHybridMissingLocalSourceNeverStartsDaemon(t *testing.T) {
39+ dir := t.TempDir()
40+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
41+ defer cancel()
42+ dialer := dialOnlyLocalDialer{dir: dir}
43+ require.Error(t, func() error {
44+ conn, err := dialer.Dial(ctx)
45+ if err != nil {
46+ return err
47+ }
48+ _ = conn.Close()
49+ return nil
50+ }(), "missing local control source must fail instead of starting a daemon")
51+}
M internal/app/remote_final_acceptance_test.go +0 -3
@@ -123,9 +123,6 @@ func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
123 outputs := make(chan string, 4)123 outputs := make(chan string, 4)
124 factory := &acceptanceRemoteFactory{outputs: outputs}124 factory := &acceptanceRemoteFactory{outputs: outputs}
125 hostStore := portsmocks.NewMockRemoteHostStore(t)125 hostStore := portsmocks.NewMockRemoteHostStore(t)
126- for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
127- hostStore.EXPECT().Remember(host).Return(nil).Once()
128- }
129 localCalls := 0126 localCalls := 0
130 terminals := make([]*acceptanceRemoteTerminal, 0, 3)127 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
131 deps := runAttachDeps{128 deps := runAttachDeps{
M internal/app/remote_hosts.go +0 -24
@@ -258,27 +258,3 @@ func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.Remo
258 }258 }
259 return out259 return out
260 }260 }
261-
262-type remoteHostLearner struct {
263- store ports.RemoteHostStore
264- target string
265-}
266-
267-func (l remoteHostLearner) RememberRemoteHost() error {
268- return l.store.Remember(l.target)
269-}
270-
271-func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner {
272- if remoteTarget == "" {
273- return nil
274- }
275- store := deps.hostStore
276- if store == nil {
277- stateDir := deps.stateDir
278- if stateDir == nil {
279- stateDir = platform.StateDir
280- }
281- store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir()))
282- }
283- return remoteHostLearner{store: store, target: remoteTarget}
284-}
M internal/app/remote_test.go +3 -27
@@ -304,41 +304,17 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
304 }304 }
305 }305 }
306 306
307-func TestRunAttachWithDepsRemoteLearning(t *testing.T) {307+func TestRunAttachWithDepsDoesNotLearnDirectRemoteHost(t *testing.T) {
308 store := portsmocks.NewMockRemoteHostStore(t)308 store := portsmocks.NewMockRemoteHostStore(t)
309- store.EXPECT().Remember("build@mule").Return(nil).Once()
310 factory := newRemoteDialerFactoryMock(t)309 factory := newRemoteDialerFactoryMock(t)
311- factory.EXPECT().DialerForRemote("build@mule", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()310+ factory.EXPECT().DialerForRemote("build@mule", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
312 311
313- var learner ports.RemoteHostLearner
314 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{312 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
315 remoteDialerFactory: factory.DialerForRemote,313 remoteDialerFactory: factory.DialerForRemote,
316 hostStore: store,314 hostStore: store,
317- runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {315+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
318- learner = deps.RemoteHostLearner
319 return nil316 return nil
320 },317 },
321 })318 })
322 require.NoError(t, err)319 require.NoError(t, err)
323- require.NotNil(t, learner)
324- require.NoError(t, learner.RememberRemoteHost())
325-}
326-
327-func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
328- store := portsmocks.NewMockRemoteHostStore(t)
329- store.EXPECT().Remember("arch").Return(nil).Once()
330- factory := newRemoteDialerFactoryMock(t)
331- factory.EXPECT().DialerForRemote("arch", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
332- var learner ports.RemoteHostLearner
333- err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "arch", "", nil, runAttachDeps{
334- remoteDialerFactory: factory.DialerForRemote,
335- hostStore: store,
336- runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
337- learner = deps.RemoteHostLearner
338- return nil
339- },
340- })
341- require.NoError(t, err)
342- require.NotNil(t, learner)
343- require.NoError(t, learner.RememberRemoteHost())
344 }320 }
M internal/app/run.go +16 -51
@@ -1002,7 +1002,9 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1002 localDialer = defaultLocalDialer1002 localDialer = defaultLocalDialer
1003 }1003 }
1004 mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)1004 mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
1005- factory := deps.remoteDialerFactory1005+ if deps.remoteDialerFactory == nil {
1006+ deps.remoteDialerFactory = defaultRemoteDialerFactory()
1007+ }
1006 var remoteSelection *domain.RemoteSessionTarget1008 var remoteSelection *domain.RemoteSessionTarget
1007 var remoteEnvironmentPolicy protocol.EnvironmentPolicy1009 var remoteEnvironmentPolicy protocol.EnvironmentPolicy
1008 remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget)1010 remoteDisplayOrigin := domain.RemoteDisplayOrigin(remoteTarget)
@@ -1014,9 +1016,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1014 if modeErr != nil {1016 if modeErr != nil {
1015 return modeErr1017 return modeErr
1016 }1018 }
1017- if factory == nil {
1018- factory = defaultRemoteDialerFactory()
1019- }
1020 }1019 }
1021 pickerHandoff := remoteTarget == ""1020 pickerHandoff := remoteTarget == ""
1022 pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {1021 pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, intent uint8, policy protocol.EnvironmentPolicy) protocol.EnvironmentPolicy {
@@ -1025,42 +1024,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1025 }1024 }
1026 return policy1025 return policy
1027 }1026 }
1028- handoff := func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {1027+ // The launching client owns one host registry for the whole run: endpoint
1029- if err := validateRemoteAttachHandoff(target); err != nil {1028+ // bindings are resolved once and reused by every later handoff, and the
1030- return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)1029+ // discovery loop it drives lives exactly as long as the runner.
1031- }1030+ registry := newClientHostRegistry(deps, mode, modeErr, log)
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- }
1064 1031
1065 handoffAttempts := 01032 handoffAttempts := 0
1066 for {1033 for {
@@ -1069,12 +1036,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1069 if log != nil {1036 if log != nil {
1070 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))1037 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
1071 }1038 }
1072- dialer, dialErr := factory(remoteTarget, name, mode, log)1039+ // The initial remote attach resolves through the same registry as
1073- if dialErr != nil {1040+ // every later handoff, so one endpoint has one carriage authority.
1074- return dialErr1041+ binding, resolveErr := registry.ResolveEndpoint(ctx, remoteTarget)
1042+ if resolveErr != nil {
1043+ return resolveErr
1075 }1044 }
1076 err = runClient(ctx, client.Dependencies{1045 err = runClient(ctx, client.Dependencies{
1077- Dialer: sessionwire.NewClientDialer(dialer),1046+ Dialer: binding.Dialer,
1078 LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),1047 LocalControlDialer: sessionwire.NewClientDialer(dialOnlyLocalDialer{dir: ipc.SocketDir(), observer: deps.runtimeObserver}),
1079 Terminal: clientTerminal(deps),1048 Terminal: clientTerminal(deps),
1080 Clock: clientClock(deps),1049 Clock: clientClock(deps),
@@ -1083,8 +1052,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1083 Clipboard: deps.clipboard,1052 Clipboard: deps.clipboard,
1084 Logger: log,1053 Logger: log,
1085 RuntimeObserver: deps.runtimeObserver,1054 RuntimeObserver: deps.runtimeObserver,
1086- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),1055+ HostRegistry: registry,
1087- AttachHandoff: handoff,
1088 Remote: true,1056 Remote: true,
1089 Origin: protocol.RouteOriginRemote,1057 Origin: protocol.RouteOriginRemote,
1090 OriginKey: remoteTarget,1058 OriginKey: remoteTarget,
@@ -1092,7 +1060,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1092 Intent: intent,1060 Intent: intent,
1093 SessionName: name,1061 SessionName: name,
1094 Remote: true,1062 Remote: true,
1095- Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget),1063+ Environment: binding.Environment,
1096 Origin: routeOrigin,1064 Origin: routeOrigin,
1097 OriginKey: routeOriginKey,1065 OriginKey: routeOriginKey,
1098 RemoteTarget: remoteSelection,1066 RemoteTarget: remoteSelection,
@@ -1113,7 +1081,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1113 Clipboard: deps.clipboard,1081 Clipboard: deps.clipboard,
1114 Logger: log,1082 Logger: log,
1115 RuntimeObserver: deps.runtimeObserver,1083 RuntimeObserver: deps.runtimeObserver,
1116- AttachHandoff: handoff,1084+ HostRegistry: registry,
1117 Remote: false,1085 Remote: false,
1118 Origin: protocol.RouteOriginLocal,1086 Origin: protocol.RouteOriginLocal,
1119 OriginKey: "local",1087 OriginKey: "local",
@@ -1150,9 +1118,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1150 if modeErr != nil {1118 if modeErr != nil {
1151 return modeErr1119 return modeErr
1152 }1120 }
1153- if factory == nil {
1154- factory = defaultRemoteDialerFactory()
1155- }
1156 }1121 }
1157 }1122 }
1158 1123
M internal/app/run_test.go +40 -29
@@ -918,7 +918,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
918 var gotClipboard ports.ClipboardReader918 var gotClipboard ports.ClipboardReader
919 clip := portsmocks.NewMockClipboardReader(t)919 clip := portsmocks.NewMockClipboardReader(t)
920 factory := newRemoteDialerFactoryMock(t)920 factory := newRemoteDialerFactoryMock(t)
921- factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)921+ factory.EXPECT().DialerForRemote("remote.example", "", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
922 922
923 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{923 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
924 remoteDialerFactory: factory.DialerForRemote,924 remoteDialerFactory: factory.DialerForRemote,
@@ -954,33 +954,36 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
954 }954 }
955 }955 }
956 956
957-func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {957+// TestRunAttachWithDepsDirectAttachOwnsOneEndpointAuthority pins that the
958+// launching client resolves its endpoint through the registry the composition
959+// root injects: the runner receives that registry, and the endpoint is resolved
960+// exactly once for the route it starts on.
961+func TestRunAttachWithDepsDirectAttachOwnsOneEndpointAuthority(t *testing.T) {
958 factory := newRemoteDialerFactoryMock(t)962 factory := newRemoteDialerFactoryMock(t)
959- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()963+ factory.EXPECT().DialerForRemote("remote.example", "", 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()
961 964
965+ var registryDials int
962 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{966 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
963 remoteDialerFactory: factory.DialerForRemote,967 remoteDialerFactory: factory.DialerForRemote,
964 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {968 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
965 require.True(t, request.Remote)969 require.True(t, request.Remote)
966 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)970 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
967- nextDialer, nextRequest, err := deps.AttachHandoff(protocol.AttachTarget{971+ require.NotNil(t, deps.HostRegistry, "a launched client must own a host registry")
968- Endpoint: "selected.example", Session: "picked", Intent: protocol.IntentAttach,972+ binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), "remote.example")
969- EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,973+ require.NoError(t, resolveErr)
970- })974+ require.NotNil(t, binding.Dialer)
971- require.NoError(t, err)975+ registryDials++
972- require.NotNil(t, nextDialer)
973- require.Equal(t, protocol.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy)
974 return nil976 return nil
975 },977 },
976 })978 })
977 require.NoError(t, err)979 require.NoError(t, err)
980+ require.Equal(t, 1, registryDials)
978 }981 }
979 982
980 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {983 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
981 factory := newRemoteDialerFactoryMock(t)984 factory := newRemoteDialerFactoryMock(t)
982- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()985+ factory.EXPECT().DialerForRemote("remote.example", "", 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()986+ factory.EXPECT().DialerForRemote("selected.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
984 var calls int987 var calls int
985 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{988 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
986 remoteDialerFactory: factory.DialerForRemote,989 remoteDialerFactory: factory.DialerForRemote,
@@ -999,29 +1002,37 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
999 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")1002 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
1000 }1003 }
1001 1004
1002-func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {1005+// TestRunAttachWithDepsReusesOneEndpointBinding pins that the registry the
1006+// runner receives resolves each endpoint once: revisiting a host, even for a
1007+// different session, hands back the same typed dialer and never rebuilds the
1008+// carriage.
1009+func TestRunAttachWithDepsReusesOneEndpointBinding(t *testing.T) {
1003 factory := newRemoteDialerFactoryMock(t)1010 factory := newRemoteDialerFactoryMock(t)
1004- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()1011+ factory.EXPECT().DialerForRemote("remote.example", "", 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()
1006 1012
1013+ var first, second ports.ClientDialer
1007 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1014 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1008 remoteDialerFactory: factory.DialerForRemote,1015 remoteDialerFactory: factory.DialerForRemote,
1009 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {1016 runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
1010- dialer, request, err := deps.AttachHandoff(protocol.AttachTarget{1017+ resolve := func() ports.ClientDialer {
1011- Endpoint: "remote.example", Session: "work", Intent: protocol.IntentAttach,1018+ binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), "remote.example")
1012- })1019+ require.NoError(t, resolveErr)
1013- require.NoError(t, err)1020+ return binding.Dialer
1014- require.NotNil(t, dialer)1021+ }
1015- require.Equal(t, "work", request.SessionName)1022+ first, second = resolve(), resolve()
1016 return nil1023 return nil
1017 },1024 },
1018 })1025 })
1019 require.NoError(t, err)1026 require.NoError(t, err)
1027+ require.NotNil(t, first)
1028+ require.Equal(t, first, second, "revisiting an endpoint must reuse its binding")
1020 }1029 }
1021 1030
1022 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {1031 func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
1023 factory := newRemoteDialerFactoryMock(t)1032 factory := newRemoteDialerFactoryMock(t)
1024- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)1033+ // The registry resolves the endpoint once: every repeated handoff reuses
1034+ // that binding, and the attempt bound is what the loop still enforces.
1035+ factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1025 1036
1026 calls := 01037 calls := 0
1027 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1038 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
@@ -1047,7 +1058,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
1047 } {1058 } {
1048 t.Run(tt.name, func(t *testing.T) {1059 t.Run(tt.name, func(t *testing.T) {
1049 factory := newRemoteDialerFactoryMock(t)1060 factory := newRemoteDialerFactoryMock(t)
1050- factory.EXPECT().DialerForRemote("selected.example", "picked", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(2)1061+ factory.EXPECT().DialerForRemote("selected.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1051 target := protocol.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: tt.intent, RequestID: tt.requestID, EnvironmentPolicy: tt.policy}1062 target := protocol.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: tt.intent, RequestID: tt.requestID, EnvironmentPolicy: tt.policy}
1052 want := client.AttachRequest{Intent: tt.intent, SessionName: "picked", Remote: true, Origin: protocol.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}1063 want := client.AttachRequest{Intent: tt.intent, SessionName: "picked", Remote: true, Origin: protocol.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
1053 localCalls, remoteCalls := 0, 01064 localCalls, remoteCalls := 0, 0
@@ -1062,9 +1073,9 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
1062 }1073 }
1063 localCalls++1074 localCalls++
1064 require.Equal(t, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local"}, request)1075 require.Equal(t, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local"}, request)
1065- _, next, err := deps.AttachHandoff(target)1076+ binding, resolveErr := deps.HostRegistry.ResolveEndpoint(context.Background(), target.Endpoint)
1066- require.NoError(t, err)1077+ require.NoError(t, resolveErr)
1067- require.Equal(t, want, next, "in-run handoff must preserve environment ownership")1078+ require.NotNil(t, binding.Dialer)
1068 return &client.AttachTargetError{Target: target}1079 return &client.AttachTargetError{Target: target}
1069 },1080 },
1070 })1081 })
@@ -1098,7 +1109,7 @@ func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
1098 for _, tt := range tests {1109 for _, tt := range tests {
1099 t.Run(tt.name, func(t *testing.T) {1110 t.Run(tt.name, func(t *testing.T) {
1100 factory := newRemoteDialerFactoryMock(t)1111 factory := newRemoteDialerFactoryMock(t)
1101- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()1112+ factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1102 calls := 01113 calls := 0
1103 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1114 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1104 remoteDialerFactory: factory.DialerForRemote,1115 remoteDialerFactory: factory.DialerForRemote,
@@ -1136,7 +1147,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
1136 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {1147 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1137 factoryErr := errors.New("factory failed")1148 factoryErr := errors.New("factory failed")
1138 factory := newRemoteDialerFactoryMock(t)1149 factory := newRemoteDialerFactoryMock(t)
1139- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr)1150+ factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportUDP, mock.Anything).Return(nil, factoryErr)
1140 runClientCalled := false1151 runClientCalled := false
1141 1152
1142 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{1153 err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
M internal/app/transport_observability_test.go +1 -1
@@ -225,7 +225,7 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
225 t.Cleanup(func() { newPerformanceTrace = originalTrace })225 t.Cleanup(func() { newPerformanceTrace = originalTrace })
226 226
227 factory := newRemoteDialerFactoryMock(t)227 factory := newRemoteDialerFactoryMock(t)
228- factory.EXPECT().DialerForRemote("remote.example", "work", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)228+ factory.EXPECT().DialerForRemote("remote.example", "", remoteadapter.TransportStdio, mock.Anything).Return(namedDialer{name: "remote"}, nil)
229 originalFactory := newRemoteDialerFactoryWithRuntimeObserver229 originalFactory := newRemoteDialerFactoryWithRuntimeObserver
230 factoryCalls := 0230 factoryCalls := 0
231 newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget {231 newRemoteDialerFactoryWithRuntimeObserver = func(got ports.SerializedRuntimeObserver) remoteDialerForTarget {
M internal/app/ui_driver.go +8 -1
@@ -573,11 +573,18 @@ func clientClock(deps runAttachDeps) ports.Clock {
573 return clock.New()573 return clock.New()
574 }574 }
575 575
576+// clientEnvironment returns the endpoint's environment as a defensive copy. An
577+// endpoint configured with an explicitly empty environment stays empty: only an
578+// endpoint with no configured environment reports nil.
576 func clientEnvironment(factory func(string) []string, target string) []string {579 func clientEnvironment(factory func(string) []string, target string) []string {
577 if factory == nil {580 if factory == nil {
578 return nil581 return nil
579 }582 }
580- return append([]string(nil), factory(target)...)583+ environment := factory(target)
584+ if environment == nil {
585+ return nil
586+ }
587+ return append([]string{}, environment...)
581 }588 }
582 589
583 func runUIDriver(ctx context.Context, options uiDriverOptions) error {590 func runUIDriver(ctx context.Context, options uiDriverOptions) error {
M internal/ports/mocks/remote_mock.go +187 -71
@@ -8,6 +8,7 @@ import (
8 "context"8 "context"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/catalogue"13 "github.com/bnema/vev/internal/protocol/catalogue"
13 mock "github.com/stretchr/testify/mock"14 mock "github.com/stretchr/testify/mock"
@@ -367,77 +368,6 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
367 return _c368 return _c
368 }369 }
369 370
370-// NewMockRemoteHostLearner creates a new instance of MockRemoteHostLearner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
371-// The first argument is typically a *testing.T value.
372-func NewMockRemoteHostLearner(t interface {
373- mock.TestingT
374- Cleanup(func())
375-}) *MockRemoteHostLearner {
376- mock := &MockRemoteHostLearner{}
377- mock.Mock.Test(t)
378-
379- t.Cleanup(func() { mock.AssertExpectations(t) })
380-
381- return mock
382-}
383-
384-// MockRemoteHostLearner is an autogenerated mock type for the RemoteHostLearner type
385-type MockRemoteHostLearner struct {
386- mock.Mock
387-}
388-
389-type MockRemoteHostLearner_Expecter struct {
390- mock *mock.Mock
391-}
392-
393-func (_m *MockRemoteHostLearner) EXPECT() *MockRemoteHostLearner_Expecter {
394- return &MockRemoteHostLearner_Expecter{mock: &_m.Mock}
395-}
396-
397-// RememberRemoteHost provides a mock function for the type MockRemoteHostLearner
398-func (_mock *MockRemoteHostLearner) RememberRemoteHost() error {
399- ret := _mock.Called()
400-
401- if len(ret) == 0 {
402- panic("no return value specified for RememberRemoteHost")
403- }
404-
405- var r0 error
406- if returnFunc, ok := ret.Get(0).(func() error); ok {
407- r0 = returnFunc()
408- } else {
409- r0 = ret.Error(0)
410- }
411- return r0
412-}
413-
414-// MockRemoteHostLearner_RememberRemoteHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RememberRemoteHost'
415-type MockRemoteHostLearner_RememberRemoteHost_Call struct {
416- *mock.Call
417-}
418-
419-// RememberRemoteHost is a helper method to define mock.On call
420-func (_e *MockRemoteHostLearner_Expecter) RememberRemoteHost() *MockRemoteHostLearner_RememberRemoteHost_Call {
421- return &MockRemoteHostLearner_RememberRemoteHost_Call{Call: _e.mock.On("RememberRemoteHost")}
422-}
423-
424-func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Run(run func()) *MockRemoteHostLearner_RememberRemoteHost_Call {
425- _c.Call.Run(func(args mock.Arguments) {
426- run()
427- })
428- return _c
429-}
430-
431-func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Return(err error) *MockRemoteHostLearner_RememberRemoteHost_Call {
432- _c.Call.Return(err)
433- return _c
434-}
435-
436-func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) RunAndReturn(run func() error) *MockRemoteHostLearner_RememberRemoteHost_Call {
437- _c.Call.Return(run)
438- return _c
439-}
440-
441 // 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.371 // NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
442 // The first argument is typically a *testing.T value.372 // The first argument is typically a *testing.T value.
443 func NewMockRemoteCatalogClient(t interface {373 func NewMockRemoteCatalogClient(t interface {
@@ -768,3 +698,189 @@ func (_c *MockRemoteCatalogCache_Store_Call) RunAndReturn(run func(remoteCatalog
768 _c.Call.Return(run)698 _c.Call.Return(run)
769 return _c699 return _c
770 }700 }
701+
702+// NewMockRemoteEndpointFactory creates a new instance of MockRemoteEndpointFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
703+// The first argument is typically a *testing.T value.
704+func NewMockRemoteEndpointFactory(t interface {
705+ mock.TestingT
706+ Cleanup(func())
707+}) *MockRemoteEndpointFactory {
708+ mock := &MockRemoteEndpointFactory{}
709+ mock.Mock.Test(t)
710+
711+ t.Cleanup(func() { mock.AssertExpectations(t) })
712+
713+ return mock
714+}
715+
716+// MockRemoteEndpointFactory is an autogenerated mock type for the RemoteEndpointFactory type
717+type MockRemoteEndpointFactory struct {
718+ mock.Mock
719+}
720+
721+type MockRemoteEndpointFactory_Expecter struct {
722+ mock *mock.Mock
723+}
724+
725+func (_m *MockRemoteEndpointFactory) EXPECT() *MockRemoteEndpointFactory_Expecter {
726+ return &MockRemoteEndpointFactory_Expecter{mock: &_m.Mock}
727+}
728+
729+// ResolveEndpoint provides a mock function for the type MockRemoteEndpointFactory
730+func (_mock *MockRemoteEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
731+ ret := _mock.Called(ctx, endpoint)
732+
733+ if len(ret) == 0 {
734+ panic("no return value specified for ResolveEndpoint")
735+ }
736+
737+ var r0 ports.RemoteEndpointBinding
738+ var r1 error
739+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteEndpointBinding, error)); ok {
740+ return returnFunc(ctx, endpoint)
741+ }
742+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteEndpointBinding); ok {
743+ r0 = returnFunc(ctx, endpoint)
744+ } else {
745+ r0 = ret.Get(0).(ports.RemoteEndpointBinding)
746+ }
747+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
748+ r1 = returnFunc(ctx, endpoint)
749+ } else {
750+ r1 = ret.Error(1)
751+ }
752+ return r0, r1
753+}
754+
755+// MockRemoteEndpointFactory_ResolveEndpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEndpoint'
756+type MockRemoteEndpointFactory_ResolveEndpoint_Call struct {
757+ *mock.Call
758+}
759+
760+// ResolveEndpoint is a helper method to define mock.On call
761+// - ctx context.Context
762+// - endpoint string
763+func (_e *MockRemoteEndpointFactory_Expecter) ResolveEndpoint(ctx any, endpoint any) *MockRemoteEndpointFactory_ResolveEndpoint_Call {
764+ return &MockRemoteEndpointFactory_ResolveEndpoint_Call{Call: _e.mock.On("ResolveEndpoint", ctx, endpoint)}
765+}
766+
767+func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) Run(run func(ctx context.Context, endpoint string)) *MockRemoteEndpointFactory_ResolveEndpoint_Call {
768+ _c.Call.Run(func(args mock.Arguments) {
769+ var arg0 context.Context
770+ if args[0] != nil {
771+ arg0 = args[0].(context.Context)
772+ }
773+ var arg1 string
774+ if args[1] != nil {
775+ arg1 = args[1].(string)
776+ }
777+ run(
778+ arg0,
779+ arg1,
780+ )
781+ })
782+ return _c
783+}
784+
785+func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) Return(remoteEndpointBinding ports.RemoteEndpointBinding, err error) *MockRemoteEndpointFactory_ResolveEndpoint_Call {
786+ _c.Call.Return(remoteEndpointBinding, err)
787+ return _c
788+}
789+
790+func (_c *MockRemoteEndpointFactory_ResolveEndpoint_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error)) *MockRemoteEndpointFactory_ResolveEndpoint_Call {
791+ _c.Call.Return(run)
792+ return _c
793+}
794+
795+// NewMockClientHostRegistry creates a new instance of MockClientHostRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
796+// The first argument is typically a *testing.T value.
797+func NewMockClientHostRegistry(t interface {
798+ mock.TestingT
799+ Cleanup(func())
800+}) *MockClientHostRegistry {
801+ mock := &MockClientHostRegistry{}
802+ mock.Mock.Test(t)
803+
804+ t.Cleanup(func() { mock.AssertExpectations(t) })
805+
806+ return mock
807+}
808+
809+// MockClientHostRegistry is an autogenerated mock type for the ClientHostRegistry type
810+type MockClientHostRegistry struct {
811+ mock.Mock
812+}
813+
814+type MockClientHostRegistry_Expecter struct {
815+ mock *mock.Mock
816+}
817+
818+func (_m *MockClientHostRegistry) EXPECT() *MockClientHostRegistry_Expecter {
819+ return &MockClientHostRegistry_Expecter{mock: &_m.Mock}
820+}
821+
822+// ResolveEndpoint provides a mock function for the type MockClientHostRegistry
823+func (_mock *MockClientHostRegistry) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
824+ ret := _mock.Called(ctx, endpoint)
825+
826+ if len(ret) == 0 {
827+ panic("no return value specified for ResolveEndpoint")
828+ }
829+
830+ var r0 ports.RemoteEndpointBinding
831+ var r1 error
832+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteEndpointBinding, error)); ok {
833+ return returnFunc(ctx, endpoint)
834+ }
835+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteEndpointBinding); ok {
836+ r0 = returnFunc(ctx, endpoint)
837+ } else {
838+ r0 = ret.Get(0).(ports.RemoteEndpointBinding)
839+ }
840+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
841+ r1 = returnFunc(ctx, endpoint)
842+ } else {
843+ r1 = ret.Error(1)
844+ }
845+ return r0, r1
846+}
847+
848+// MockClientHostRegistry_ResolveEndpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveEndpoint'
849+type MockClientHostRegistry_ResolveEndpoint_Call struct {
850+ *mock.Call
851+}
852+
853+// ResolveEndpoint is a helper method to define mock.On call
854+// - ctx context.Context
855+// - endpoint string
856+func (_e *MockClientHostRegistry_Expecter) ResolveEndpoint(ctx any, endpoint any) *MockClientHostRegistry_ResolveEndpoint_Call {
857+ return &MockClientHostRegistry_ResolveEndpoint_Call{Call: _e.mock.On("ResolveEndpoint", ctx, endpoint)}
858+}
859+
860+func (_c *MockClientHostRegistry_ResolveEndpoint_Call) Run(run func(ctx context.Context, endpoint string)) *MockClientHostRegistry_ResolveEndpoint_Call {
861+ _c.Call.Run(func(args mock.Arguments) {
862+ var arg0 context.Context
863+ if args[0] != nil {
864+ arg0 = args[0].(context.Context)
865+ }
866+ var arg1 string
867+ if args[1] != nil {
868+ arg1 = args[1].(string)
869+ }
870+ run(
871+ arg0,
872+ arg1,
873+ )
874+ })
875+ return _c
876+}
877+
878+func (_c *MockClientHostRegistry_ResolveEndpoint_Call) Return(remoteEndpointBinding ports.RemoteEndpointBinding, err error) *MockClientHostRegistry_ResolveEndpoint_Call {
879+ _c.Call.Return(remoteEndpointBinding, err)
880+ return _c
881+}
882+
883+func (_c *MockClientHostRegistry_ResolveEndpoint_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error)) *MockClientHostRegistry_ResolveEndpoint_Call {
884+ _c.Call.Return(run)
885+ return _c
886+}
M internal/ports/mocks/ui_mock.go +1 -6
@@ -365,13 +365,8 @@ func (_c *MockUIObservationSink_ObserveTerminalResize_Call) RunAndReturn(run fun
365 }365 }
366 366
367 // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink367 // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink
368-//
369-// NOTE(hand-edit): the defensive copy below is intentional. Callers pass
370-// sub-slices of reused buffers, so recording the slice header without
371-// copying makes assertions flaky. `make mocks` regenerates this file via
372-// mockery and drops the copy; re-apply it after regeneration.
373 func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) {368 func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) {
374- _mock.Called(append([]byte(nil), data...))369+ _mock.Called(data)
375 return370 return
376 }371 }
377 372
M internal/ports/remote.go +22 -5
@@ -21,11 +21,6 @@ type RemoteHostStore interface {
21 Remove(target string) (deleted bool, err error)21 Remove(target string) (deleted bool, err error)
22 }22 }
23 23
24-// RemoteHostLearner records the validated remote target after an attach.
25-type RemoteHostLearner interface {
26- RememberRemoteHost() error
27-}
28-
29 // RemoteCatalogClient fetches a versioned session catalogue from a remote host.24 // RemoteCatalogClient fetches a versioned session catalogue from a remote host.
30 type RemoteCatalogClient interface {25 type RemoteCatalogClient interface {
31 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)26 List(ctx context.Context, target string) (catalogue.RemoteCatalog, error)
@@ -42,3 +37,25 @@ type RemoteCatalogCache interface {
42 Load() ([]catalogue.RemoteCatalogCacheEntry, error)37 Load() ([]catalogue.RemoteCatalogCacheEntry, error)
43 Store([]catalogue.RemoteCatalogCacheEntry) error38 Store([]catalogue.RemoteCatalogCacheEntry) error
44 }39 }
40+
41+// RemoteEndpointBinding is one endpoint's resolved client carriage: the typed
42+// dialer every session on that endpoint attaches through, and the environment
43+// to advertise when the remote environment is daemon-owned. It never carries
44+// session identity, a resume token, or discovered inventory.
45+type RemoteEndpointBinding struct {
46+ Dialer ClientDialer
47+ Environment []string
48+}
49+
50+// RemoteEndpointFactory resolves one endpoint into its binding. The composition
51+// root implements it from the configured transport mode, the launch allowlist,
52+// and the endpoint environment; consumers never inspect those policies.
53+type RemoteEndpointFactory interface {
54+ ResolveEndpoint(ctx context.Context, endpoint string) (RemoteEndpointBinding, error)
55+}
56+
57+// ClientHostRegistry resolves and caches the endpoint bindings one runner
58+// reuses across handoffs. Remote discovery remains daemon-owned.
59+type ClientHostRegistry interface {
60+ ResolveEndpoint(ctx context.Context, endpoint string) (RemoteEndpointBinding, error)
61+}
M internal/protocol/messages.go +12 -3
@@ -22,7 +22,6 @@ func (OutputResetRequest) clientMessage() {}
22 func (RemotePreviewRequest) clientMessage() {}22 func (RemotePreviewRequest) clientMessage() {}
23 func (RouteAttentionSubscription) clientMessage() {}23 func (RouteAttentionSubscription) clientMessage() {}
24 func (SamePeerSwitchRequest) clientMessage() {}24 func (SamePeerSwitchRequest) clientMessage() {}
25-func (ParkedRouteRequest) clientMessage() {}
26 func (RecentRouteSnapshot) clientMessage() {}25 func (RecentRouteSnapshot) clientMessage() {}
27 func (RouteNavigationFailure) clientMessage() {}26 func (RouteNavigationFailure) clientMessage() {}
28 func (SessionCreationFailure) clientMessage() {}27 func (SessionCreationFailure) clientMessage() {}
@@ -30,6 +29,11 @@ func (UIFence) clientMessage() {}
30 func (NavigationInventoryRequest) clientMessage() {}29 func (NavigationInventoryRequest) clientMessage() {}
31 func (NavigationInventoryPublication) clientMessage() {}30 func (NavigationInventoryPublication) clientMessage() {}
32 func (NavigationInventoryFailure) clientMessage() {}31 func (NavigationInventoryFailure) clientMessage() {}
32+func (PickerBegin) clientMessage() {}
33+func (PickerClose) clientMessage() {}
34+func (PickerSelection) clientMessage() {}
35+func (PickerPreviewRequest) clientMessage() {}
36+func (PickerControlRequest) clientMessage() {}
33 37
34 func (Welcome) serverMessage() {}38 func (Welcome) serverMessage() {}
35 func (ErrorMsg) serverMessage() {}39 func (ErrorMsg) serverMessage() {}
@@ -38,7 +42,6 @@ func (Detached) serverMessage() {}
38 func (Pong) serverMessage() {}42 func (Pong) serverMessage() {}
39 func (Sessions) serverMessage() {}43 func (Sessions) serverMessage() {}
40 func (CommandResult) serverMessage() {}44 func (CommandResult) serverMessage() {}
41-func (NavigationDirective) serverMessage() {}
42 func (AttachTarget) serverMessage() {}45 func (AttachTarget) serverMessage() {}
43 func (RemotePreview) serverMessage() {}46 func (RemotePreview) serverMessage() {}
44 func (CommittedRouteIdentity) serverMessage() {}47 func (CommittedRouteIdentity) serverMessage() {}
@@ -48,9 +51,15 @@ func (RouteNavigationFailure) serverMessage() {}
48 func (RoutePosition) serverMessage() {}51 func (RoutePosition) serverMessage() {}
49 func (RouteRetired) serverMessage() {}52 func (RouteRetired) serverMessage() {}
50 func (SamePeerSwitchFailure) serverMessage() {}53 func (SamePeerSwitchFailure) serverMessage() {}
51-func (ParkedRouteResponse) serverMessage() {}
52 func (UIReceipt) serverMessage() {}54 func (UIReceipt) serverMessage() {}
53 func (UIViewUpdate) serverMessage() {}55 func (UIViewUpdate) serverMessage() {}
54 func (NavigationInventoryResponse) serverMessage() {}56 func (NavigationInventoryResponse) serverMessage() {}
55 func (NavigationInventoryDemand) serverMessage() {}57 func (NavigationInventoryDemand) serverMessage() {}
56 func (NavigationInventorySelection) serverMessage() {}58 func (NavigationInventorySelection) serverMessage() {}
59+func (PickerOffer) serverMessage() {}
60+func (PickerSnapshot) serverMessage() {}
61+func (PickerClosed) serverMessage() {}
62+func (PickerResult) serverMessage() {}
63+func (PickerFailure) serverMessage() {}
64+func (PickerPreview) serverMessage() {}
65+func (PickerControlResponse) serverMessage() {}
M internal/protocol/navigation.go +17 -104
@@ -1,121 +1,34 @@
1 package protocol1 package protocol
2 2
3-import "github.com/bnema/vev/internal/domain"3+// NavigationCapabilities advertises optional client-driven navigation
4-4+// features. Presenting a picker is not one of them: every attached client
5+// renders the picker itself, so the serving daemon only negotiates the
6+// features it must act on.
5 type NavigationCapabilities uint87 type NavigationCapabilities uint8
6 8
7 const (9 const (
8- NavigationCapabilityHomePicker NavigationCapabilities = 1 << iota10+ NavigationCapabilityInventory 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
61 )11 )
62 12
63-type ParkedRouteResponse struct {
64- RequestID uint64
65- Status ParkedRouteStatus
66-}
67-
68 func validNavigationCapabilities(capabilities NavigationCapabilities) bool {13 func validNavigationCapabilities(capabilities NavigationCapabilities) bool {
69- return capabilities&^(NavigationCapabilityHomePicker|NavigationCapabilityBack|NavigationCapabilityInventory) == 014+ return capabilities&^NavigationCapabilityInventory == 0
70-}
71-
72-func validStartupOverlay(overlay StartupOverlay) bool {
73- return overlay == StartupOverlayNone || overlay == StartupOverlaySessionPicker
74 }15 }
75 16
76-func ValidateNavigation(capabilities NavigationCapabilities, overlay StartupOverlay, homePickerRoute bool) error {17+// ValidateNavigation rejects capabilities this protocol does not define and
77- if !validNavigationCapabilities(capabilities) || !validStartupOverlay(overlay) {18+// accepts the inventory capability only on the intents whose attachment can
78- return ErrInvalidNavigation19+// serve it. Both the client before dialing and the serving daemon on Hello use
79- }20+// this rule, so a request the client admits is one the daemon accepts.
80- home := capabilities&NavigationCapabilityHomePicker != 021+func ValidateNavigation(intent uint8, capabilities NavigationCapabilities) error {
81- back := capabilities&NavigationCapabilityBack != 022+ if !validNavigationCapabilities(capabilities) {
82- if (home && !homePickerRoute) || back != (overlay == StartupOverlaySessionPicker) {
83- return ErrInvalidNavigation
84- }
85- if homePickerRoute && back {
86 return ErrInvalidNavigation23 return ErrInvalidNavigation
87 }24 }
88- return nil25+ switch intent {
89-}26+ case IntentNew, IntentAttach, IntentResume:
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- }
99 return nil27 return nil
100- }28+ default:
101- return ValidateNavigation(h.NavigationCapabilities, h.StartupOverlay, h.RemoteTarget != nil || h.EnvironmentPolicy == EnvironmentPolicyDaemonOwned)29+ if capabilities != 0 {
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 {
115 return ErrInvalidNavigation30 return ErrInvalidNavigation
116 }31 }
117- default:32+ return nil
118- return ErrInvalidNavigation
119 }33 }
120- return nil
121 }34 }
M internal/protocol/navigation_inventory_test.go +28 -1
@@ -64,7 +64,34 @@ func TestValidateNavigationInventoryResponseUnions(t *testing.T) {
64 }64 }
65 65
66 func TestNavigationCapabilityInventoryValid(t *testing.T) {66 func TestNavigationCapabilityInventoryValid(t *testing.T) {
67- if err := ValidateNavigation(NavigationCapabilityInventory, StartupOverlayNone, false); err != nil {67+ if err := ValidateNavigation(IntentAttach, NavigationCapabilityInventory); err != nil {
68 t.Fatalf("ValidateNavigation(inventory) = %v", err)68 t.Fatalf("ValidateNavigation(inventory) = %v", err)
69 }69 }
70 }70 }
71+
72+// TestValidateNavigationBindsInventoryToTheIntentsThatServeIt pins the shared
73+// client/daemon rule: inventory is negotiable on the intents whose attachment
74+// owns a relay, and no capability is admissible on the others.
75+func TestValidateNavigationBindsInventoryToTheIntentsThatServeIt(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ intent uint8
79+ capabilities NavigationCapabilities
80+ wantErr bool
81+ }{
82+ {name: "attach inventory", intent: IntentAttach, capabilities: NavigationCapabilityInventory},
83+ {name: "resume inventory", intent: IntentResume, capabilities: NavigationCapabilityInventory},
84+ {name: "new inventory", intent: IntentNew, capabilities: NavigationCapabilityInventory},
85+ {name: "ephemeral inventory", intent: IntentEphemeral, capabilities: NavigationCapabilityInventory, wantErr: true},
86+ {name: "ephemeral clean", intent: IntentEphemeral},
87+ {name: "unknown bit", intent: IntentAttach, capabilities: 1 << 7, wantErr: true},
88+ }
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ err := ValidateNavigation(tt.intent, tt.capabilities)
92+ if (err != nil) != tt.wantErr {
93+ t.Fatalf("ValidateNavigation(%d, %d) = %v, wantErr %t", tt.intent, tt.capabilities, err, tt.wantErr)
94+ }
95+ })
96+ }
97+}
A internal/protocol/picker_interaction.go +665 -0
@@ -0,0 +1,665 @@
1+package protocol
2+
3+import "fmt"
4+
5+// PickerServingSourceID names the source the serving daemon owns: every
6+// attachment publishes its own rows under this one source identity.
7+const (
8+ PickerServingSourceID = "serving"
9+ PickerHomeSourceID = "home"
10+)
11+
12+// Client-picker interaction bounds. A source publishes at most
13+// PickerInteractionMaxLines structured lines; the complete encoded snapshot
14+// stays below PickerInteractionMaxEncodedBytes, the same 4 MiB export budget
15+// shared with navigation inventory.
16+const (
17+ PickerInteractionMaxLines = 4096
18+ PickerInteractionMaxDisplayBytes = 256
19+ PickerInteractionMaxEncodedBytes = 4 << 20
20+ PickerInteractionMaxSourceBytes = 128
21+ PickerInteractionMaxTitleBytes = 256
22+)
23+
24+// PickerIntent names which destinations a picker offers and which mutation a
25+// selection performs. The daemon owns the intent: it captures move sources and
26+// decides which rows are eligible.
27+type PickerIntent uint8
28+
29+const (
30+ PickerIntentNavigation PickerIntent = iota + 1
31+ PickerIntentMovePane
32+ PickerIntentMoveTab
33+)
34+
35+// PickerAction names the mutation one selection requests.
36+type PickerAction uint8
37+
38+const (
39+ PickerActionNavigate PickerAction = iota + 1
40+ PickerActionMove
41+ PickerActionKill
42+)
43+
44+// PickerLineKind names the row shape. Section rows are group headers and
45+// carry no key; presentation-only rows (host status) stay focusable but not
46+// selectable.
47+type PickerLineKind uint8
48+
49+const (
50+ PickerLineSection PickerLineKind = iota + 1
51+ PickerLineSession
52+ PickerLineTab
53+ PickerLineHost
54+)
55+
56+// PickerLineStatus is the bounded state badge drawn on a row.
57+type PickerLineStatus uint8
58+
59+const (
60+ PickerLineStatusNone PickerLineStatus = iota
61+ PickerLineStatusUp
62+ PickerLineStatusStopped
63+ PickerLineStatusDown
64+ PickerLineStatusStale
65+ PickerLineStatusVersion
66+ PickerLineStatusError
67+)
68+
69+// PickerLineActions is the set of actions a row admits. The presenting client
70+// offers only what the owning source authorised; the source revalidates the
71+// action and the row lifecycle at commit.
72+type PickerLineActions uint8
73+
74+const (
75+ PickerCanNavigate PickerLineActions = 1 << iota
76+ PickerCanMove
77+ PickerCanKill
78+)
79+
80+// PickerSourceStatus is the per-source outcome. A failed source never
81+// invalidates another source's lines.
82+type PickerSourceStatus uint8
83+
84+const (
85+ PickerSourceOK PickerSourceStatus = iota + 1
86+ PickerSourceUnavailable
87+ PickerSourceTooLarge
88+)
89+
90+// PickerLine is one structured row. Key is opaque and source-assigned; it
91+// resolves to a target source-side only. Display strings carry presentation
92+// facts, never endpoints, credentials, environment, CWD, or pane content.
93+// Lines are published in the source's own order (recency for local sessions)
94+// and Ephemeral marks sessions created for one attach, which is what the
95+// presenting client needs to offer its local grouped order.
96+type PickerLine struct {
97+ Key string
98+ Kind PickerLineKind
99+ Label string
100+ Detail string
101+ Status PickerLineStatus
102+ StatusDetail string
103+ Attention bool
104+ Stopped bool
105+ Dim bool
106+ // Focusable reports that the source authorises this row as a cursor
107+ // destination for this interaction, independently of whether it admits an
108+ // action. A Focusable row with no Actions is deliberately inspectable and
109+ // never committable; a row that is neither Focusable nor actionable is
110+ // rendered but skipped by cursor navigation.
111+ Focusable bool
112+ Actions PickerLineActions
113+ Ephemeral bool
114+}
115+
116+// Selectable reports whether the row admits at least one action.
117+func (line PickerLine) Selectable() bool { return line.Actions != 0 }
118+
119+// PickerCursor restores the selected row by opaque key with its list index.
120+type PickerCursor struct {
121+ Key string
122+ Index int
123+}
124+
125+// PickerOffer opens one interaction. The daemon sends it for intents it
126+// initiates (a session-picker command or a move request); it carries the
127+// acquisition barrier the client must display before owning the terminal and
128+// the opaque identity of the captured move source. RequestID echoes the
129+// PickerBegin that asked for the interaction, and is zero for daemon-driven
130+// opens.
131+type PickerOffer struct {
132+ InteractionID uint64
133+ RequestID uint64
134+ Intent PickerIntent
135+ MoveSourceKey string
136+ BarrierEpoch uint64
137+ BarrierState uint64
138+ SizeEpoch uint64
139+ Title string
140+}
141+
142+// PickerBegin asks the serving daemon to open one interaction. Startup
143+// pickers and client-driven navigation requests use it; a daemon that already
144+// executed a picker command sends PickerOffer directly.
145+type PickerBegin struct {
146+ RequestID uint64
147+ Intent PickerIntent
148+}
149+
150+// PickerControlOperation selects a stateless request to the client's stable
151+// local picker authority. It never creates an attachment.
152+type PickerControlOperation uint8
153+
154+const (
155+ PickerControlSnapshot PickerControlOperation = iota + 1
156+ PickerControlResolve
157+ PickerControlObserve
158+)
159+
160+// PickerControlMaxTargets bounds one observation request. A periodic Runner
161+// polls a small fixed working set of exact targets per query; a full directory
162+// belongs to navigation inventory, not to this stateless probe.
163+const PickerControlMaxTargets = 64
164+
165+// PickerRoutePresence reports whether an exact target still resolves to one
166+// live route. Unknown is a first-class answer: an unanswerable probe must
167+// never be reported as Absent, because the caller would otherwise act on a
168+// false negative.
169+type PickerRoutePresence uint8
170+
171+const (
172+ PickerRoutePresent PickerRoutePresence = iota + 1
173+ PickerRouteAbsent
174+ PickerRouteUnknown
175+)
176+
177+// PickerRouteObservation is one exact-target answer. Attention is meaningful
178+// only for Present targets: Absent and Unknown observations always carry
179+// false, so a stale flag can never be mistaken for live attention.
180+type PickerRouteObservation struct {
181+ Target ExactSessionTarget
182+ Presence PickerRoutePresence
183+ Attention bool
184+}
185+
186+// PickerControlRequest queries, resolves, or observes the navigation picker
187+// owned by the client's local daemon. Version stays first for strict
188+// pre-handshake peeking. The operation payloads are exclusive: snapshot and
189+// resolve carry source/key identity, observe carries one bounded exact-target
190+// list, and every field an operation does not own stays zero.
191+type PickerControlRequest struct {
192+ Version uint16
193+ RequestID uint64
194+ Operation PickerControlOperation
195+ SourceRevision uint64
196+ SourceID string
197+ Key string
198+ Targets []ExactSessionTarget
199+}
200+
201+// PickerControlResponse returns one complete authoritative snapshot, one
202+// revalidated attach target, or the observation list for an observe request.
203+// The operation payloads are exclusive.
204+type PickerControlResponse struct {
205+ RequestID uint64
206+ Operation PickerControlOperation
207+ Status PickerSourceStatus
208+ Snapshot *PickerSnapshot
209+ Resolved *AttachTarget
210+ Observations []PickerRouteObservation
211+}
212+
213+// PickerProjection is one complete daemon-authorised presentation. Recent and
214+// Grouped contain the same destination keys and actions but may differ in
215+// order, sections, and display labels.
216+type PickerProjection struct {
217+ Lines []PickerLine
218+ Cursor PickerCursor
219+}
220+
221+// PickerSnapshot atomically publishes both presentation projections of one
222+// source at one revision. The client chooses a projection but never derives
223+// grouping or ordering policy from row metadata.
224+type PickerSnapshot struct {
225+ InteractionID uint64
226+ SourceID string
227+ SourceRevision uint64
228+ Status PickerSourceStatus
229+ StatusDetail string
230+ // Lines/Cursor are retained as construction aliases inside this v0 branch;
231+ // normalization copies them into both projections before encoding.
232+ Lines []PickerLine
233+ Cursor PickerCursor
234+ Recent PickerProjection
235+ Grouped PickerProjection
236+}
237+
238+// PickerSelection commits the opaque row key displayed at SourceRevision.
239+// CauseActionID correlates ui-driver input like NavigationInventorySelection.
240+// RequestID echoes the PickerBegin that opened a client-initiated
241+// interaction, and is zero otherwise.
242+type PickerSelection struct {
243+ CauseActionID uint64
244+ RequestID uint64
245+ InteractionID uint64
246+ SourceID string
247+ SourceRevision uint64
248+ Key string
249+ Action PickerAction
250+}
251+
252+// PickerResult reports a completed mutation that changed source state, so the
253+// client can refresh without guessing. Rejections use PickerFailure.
254+type PickerResult struct {
255+ CauseActionID uint64
256+ RequestID uint64
257+ InteractionID uint64
258+ SourceID string
259+ Key string
260+ Action PickerAction
261+}
262+
263+// PickerFailureCode classifies client-picker selection failures.
264+type PickerFailureCode uint8
265+
266+const (
267+ PickerStaleRevision PickerFailureCode = iota + 1
268+ PickerUnknownKey
269+ PickerRetiredTarget
270+ PickerNavigationFailed
271+ PickerUnknownSource
272+ PickerActionFailed
273+)
274+
275+// PickerFailure reports a rejected selection with a bounded code.
276+type PickerFailure struct {
277+ CauseActionID uint64
278+ RequestID uint64
279+ InteractionID uint64
280+ SourceID string
281+ Key string
282+ Action PickerAction
283+ Code PickerFailureCode
284+}
285+
286+// PickerClose asks the serving daemon to retire one interaction. It is
287+// idempotent: a close for an unknown or already retired interaction is a
288+// no-op, never an error.
289+type PickerClose struct {
290+ InteractionID uint64
291+ RequestID uint64
292+}
293+
294+// PickerClosed confirms that the serving daemon retired one interaction. The
295+// barrier names the daemon output boundary the client must display before it
296+// releases the terminal.
297+type PickerClosed struct {
298+ InteractionID uint64
299+ BarrierEpoch uint64
300+ BarrierState uint64
301+}
302+
303+func validPickerControlOperation(operation PickerControlOperation) bool {
304+ return operation == PickerControlSnapshot || operation == PickerControlResolve || operation == PickerControlObserve
305+}
306+
307+func validPickerRoutePresence(presence PickerRoutePresence) bool {
308+ switch presence {
309+ case PickerRoutePresent, PickerRouteAbsent, PickerRouteUnknown:
310+ return true
311+ default:
312+ return false
313+ }
314+}
315+
316+// ValidatePickerControlRequest enforces explicit, exclusive operation payloads.
317+// Snapshot and resolve must carry no targets; observe must carry 1..
318+// PickerControlMaxTargets distinct valid exact targets and no source/key
319+// identity, so a query can never smuggle a second payload shape.
320+func ValidatePickerControlRequest(request PickerControlRequest) error {
321+ if request.Version == 0 || request.RequestID == 0 || !validPickerControlOperation(request.Operation) {
322+ return ErrInvalidNavigation
323+ }
324+ switch request.Operation {
325+ case PickerControlSnapshot:
326+ if request.SourceRevision != 0 || request.SourceID != "" || request.Key != "" || len(request.Targets) != 0 {
327+ return ErrInvalidNavigation
328+ }
329+ return nil
330+ case PickerControlResolve:
331+ if len(request.Targets) != 0 {
332+ return ErrInvalidNavigation
333+ }
334+ if request.SourceRevision == 0 || !validPickerSourceID(request.SourceID) || !validPickerKey(request.Key) {
335+ return ErrInvalidNavigation
336+ }
337+ return nil
338+ default:
339+ if request.SourceRevision != 0 || request.SourceID != "" || request.Key != "" {
340+ return ErrInvalidNavigation
341+ }
342+ if len(request.Targets) == 0 || len(request.Targets) > PickerControlMaxTargets {
343+ return ErrInvalidNavigation
344+ }
345+ seen := make(map[ExactSessionTarget]struct{}, len(request.Targets))
346+ for _, target := range request.Targets {
347+ if err := target.Validate(); err != nil {
348+ return ErrInvalidNavigation
349+ }
350+ if _, dup := seen[target]; dup {
351+ return ErrInvalidNavigation
352+ }
353+ seen[target] = struct{}{}
354+ }
355+ return nil
356+ }
357+}
358+
359+// validatePickerRouteObservations requires one bounded, distinct observation
360+// per requested target and refuses an attention flag on anything but a present
361+// target.
362+func validatePickerRouteObservations(observations []PickerRouteObservation) error {
363+ if len(observations) == 0 || len(observations) > PickerControlMaxTargets {
364+ return ErrInvalidNavigation
365+ }
366+ seen := make(map[ExactSessionTarget]struct{}, len(observations))
367+ for _, observation := range observations {
368+ if !validPickerRoutePresence(observation.Presence) {
369+ return ErrInvalidNavigation
370+ }
371+ if err := observation.Target.Validate(); err != nil {
372+ return ErrInvalidNavigation
373+ }
374+ if observation.Presence != PickerRoutePresent && observation.Attention {
375+ return ErrInvalidNavigation
376+ }
377+ if _, dup := seen[observation.Target]; dup {
378+ return ErrInvalidNavigation
379+ }
380+ seen[observation.Target] = struct{}{}
381+ }
382+ return nil
383+}
384+
385+// ValidatePickerControlResponse enforces the exclusive response union: exactly
386+// the payload owned by the operation and status is present, and a failed
387+// status never carries payload bytes at all.
388+func ValidatePickerControlResponse(response PickerControlResponse) error {
389+ if response.RequestID == 0 || !validPickerControlOperation(response.Operation) || !validPickerSourceStatus(response.Status) {
390+ return ErrInvalidNavigation
391+ }
392+ if response.Status != PickerSourceOK {
393+ if response.Snapshot != nil || response.Resolved != nil || len(response.Observations) != 0 {
394+ return ErrInvalidNavigation
395+ }
396+ return nil
397+ }
398+ switch response.Operation {
399+ case PickerControlSnapshot:
400+ if response.Snapshot == nil || response.Resolved != nil || len(response.Observations) != 0 {
401+ return ErrInvalidNavigation
402+ }
403+ return ValidatePickerSnapshot(*response.Snapshot)
404+ case PickerControlResolve:
405+ if response.Resolved == nil || response.Snapshot != nil || len(response.Observations) != 0 {
406+ return ErrInvalidNavigation
407+ }
408+ return ValidateAttachTarget(*response.Resolved)
409+ default:
410+ if response.Snapshot != nil || response.Resolved != nil {
411+ return ErrInvalidNavigation
412+ }
413+ return validatePickerRouteObservations(response.Observations)
414+ }
415+}
416+
417+func validPickerIntent(intent PickerIntent) bool {
418+ switch intent {
419+ case PickerIntentNavigation, PickerIntentMovePane, PickerIntentMoveTab:
420+ return true
421+ default:
422+ return false
423+ }
424+}
425+
426+func validPickerAction(action PickerAction) bool {
427+ switch action {
428+ case PickerActionNavigate, PickerActionMove, PickerActionKill:
429+ return true
430+ default:
431+ return false
432+ }
433+}
434+
435+func validPickerLineKind(kind PickerLineKind) bool {
436+ switch kind {
437+ case PickerLineSection, PickerLineSession, PickerLineTab, PickerLineHost:
438+ return true
439+ default:
440+ return false
441+ }
442+}
443+
444+func validPickerLineStatus(status PickerLineStatus) bool {
445+ return status <= PickerLineStatusError
446+}
447+
448+func validPickerLineActions(actions PickerLineActions) bool {
449+ return actions&^(PickerCanNavigate|PickerCanMove|PickerCanKill) == 0
450+}
451+
452+func validPickerSourceStatus(status PickerSourceStatus) bool {
453+ switch status {
454+ case PickerSourceOK, PickerSourceUnavailable, PickerSourceTooLarge:
455+ return true
456+ default:
457+ return false
458+ }
459+}
460+
461+func validPickerFailureCode(code PickerFailureCode) bool {
462+ switch code {
463+ case PickerStaleRevision, PickerUnknownKey, PickerRetiredTarget, PickerNavigationFailed, PickerUnknownSource, PickerActionFailed:
464+ return true
465+ default:
466+ return false
467+ }
468+}
469+
470+func validPickerKey(value string) bool {
471+ return validInventoryKey(value)
472+}
473+
474+func validPickerDisplay(value string) bool {
475+ if len(value) > PickerInteractionMaxDisplayBytes {
476+ return false
477+ }
478+ return validInventoryDisplay(value)
479+}
480+
481+func validPickerSourceID(value string) bool {
482+ if value == "" || len(value) > PickerInteractionMaxSourceBytes {
483+ return false
484+ }
485+ return validInventoryDisplay(value)
486+}
487+
488+// ValidatePickerOffer enforces a nonzero interaction, a known intent, bounded
489+// display strings, and a move source only on move intents.
490+func ValidatePickerOffer(offer PickerOffer) error {
491+ if offer.InteractionID == 0 || !validPickerIntent(offer.Intent) {
492+ return ErrInvalidNavigation
493+ }
494+ if !validPickerDisplay(offer.Title) {
495+ return ErrInvalidNavigation
496+ }
497+ if offer.MoveSourceKey != "" {
498+ if offer.Intent == PickerIntentNavigation || !validPickerKey(offer.MoveSourceKey) {
499+ return ErrInvalidNavigation
500+ }
501+ }
502+ return nil
503+}
504+
505+// ValidatePickerBegin enforces a nonzero request ID and a known intent.
506+func ValidatePickerBegin(begin PickerBegin) error {
507+ if begin.RequestID == 0 || !validPickerIntent(begin.Intent) {
508+ return ErrInvalidNavigation
509+ }
510+ return nil
511+}
512+
513+// ValidatePickerSnapshot enforces a nonzero interaction/source revision, a
514+// bounded source identity, and unique opaque keys with display safety. Order
515+// is preserved verbatim: lines are never re-sorted here.
516+// ValidatePickerSnapshot enforces the envelope, the per-line shape, unique row
517+// keys, and a cursor that names a published row. Every rejection wraps
518+// ErrInvalidNavigation with the offending row, because a refused snapshot
519+// otherwise leaves an operator with nothing but a silent, closed picker.
520+func ValidatePickerSnapshot(snapshot PickerSnapshot) error {
521+ snapshot = NormalizePickerSnapshot(snapshot)
522+ if snapshot.InteractionID == 0 || snapshot.SourceRevision == 0 {
523+ return fmt.Errorf("%w: snapshot envelope", ErrInvalidNavigation)
524+ }
525+ if !validPickerSourceID(snapshot.SourceID) || !validPickerSourceStatus(snapshot.Status) {
526+ return fmt.Errorf("%w: snapshot source %q", ErrInvalidNavigation, snapshot.SourceID)
527+ }
528+ if !validPickerDisplay(snapshot.StatusDetail) {
529+ return fmt.Errorf("%w: snapshot status detail", ErrInvalidNavigation)
530+ }
531+ recentKeys, err := validatePickerProjection("recent", snapshot.Recent)
532+ if err != nil {
533+ return err
534+ }
535+ groupedKeys, err := validatePickerProjection("grouped", snapshot.Grouped)
536+ if err != nil {
537+ return err
538+ }
539+ if len(recentKeys) != len(groupedKeys) {
540+ return fmt.Errorf("%w: projection destination count", ErrInvalidNavigation)
541+ }
542+ for key, recent := range recentKeys {
543+ grouped, ok := groupedKeys[key]
544+ if !ok || recent != grouped {
545+ return fmt.Errorf("%w: projection destination %q", ErrInvalidNavigation, key)
546+ }
547+ }
548+ return nil
549+}
550+
551+func NormalizePickerSnapshot(snapshot PickerSnapshot) PickerSnapshot {
552+ if len(snapshot.Recent.Lines) == 0 && len(snapshot.Lines) != 0 {
553+ snapshot.Recent = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
554+ }
555+ if len(snapshot.Grouped.Lines) == 0 && len(snapshot.Lines) != 0 {
556+ snapshot.Grouped = PickerProjection{Lines: snapshot.Lines, Cursor: snapshot.Cursor}
557+ }
558+ return snapshot
559+}
560+
561+func validatePickerProjection(name string, projection PickerProjection) (map[string]PickerLineActions, error) {
562+ if len(projection.Lines) > PickerInteractionMaxLines {
563+ return nil, fmt.Errorf("%w: %s has %d lines", ErrInvalidNavigation, name, len(projection.Lines))
564+ }
565+ seen := make(map[string]PickerLineActions, len(projection.Lines))
566+ for i, line := range projection.Lines {
567+ if !validPickerLineKind(line.Kind) || !validPickerLineStatus(line.Status) || !validPickerLineActions(line.Actions) {
568+ return nil, fmt.Errorf("%w: %s line %d kind/status/actions", ErrInvalidNavigation, name, i)
569+ }
570+ if !validPickerDisplay(line.Label) || !validPickerDisplay(line.Detail) || !validPickerDisplay(line.StatusDetail) {
571+ return nil, fmt.Errorf("%w: %s line %d display", ErrInvalidNavigation, name, i)
572+ }
573+ // Focus eligibility is published explicitly: a row that admits an
574+ // action is always a cursor destination, and a section header is never
575+ // one. A row that is neither is rendered and skipped.
576+ if line.Actions != 0 && !line.Focusable {
577+ return nil, fmt.Errorf("%w: %s line %d action without focus", ErrInvalidNavigation, name, i)
578+ }
579+ if line.Kind == PickerLineSection {
580+ if line.Key != "" || line.Focusable || line.Actions != 0 {
581+ return nil, fmt.Errorf("%w: %s line %d section key or action", ErrInvalidNavigation, name, i)
582+ }
583+ continue
584+ }
585+ if !validPickerKey(line.Key) {
586+ return nil, fmt.Errorf("%w: %s line %d key %q", ErrInvalidNavigation, name, i, line.Key)
587+ }
588+ if _, dup := seen[line.Key]; dup {
589+ return nil, fmt.Errorf("%w: %s line %d repeats key %q", ErrInvalidNavigation, name, i, line.Key)
590+ }
591+ seen[line.Key] = line.Actions
592+ }
593+ if projection.Cursor.Key != "" {
594+ if !validPickerKey(projection.Cursor.Key) {
595+ return nil, fmt.Errorf("%w: %s cursor key %q", ErrInvalidNavigation, name, projection.Cursor.Key)
596+ }
597+ if _, ok := seen[projection.Cursor.Key]; !ok {
598+ return nil, fmt.Errorf("%w: %s cursor key %q is not a published row", ErrInvalidNavigation, name, projection.Cursor.Key)
599+ }
600+ }
601+ if projection.Cursor.Index < 0 || projection.Cursor.Index > len(projection.Lines) {
602+ return nil, fmt.Errorf("%w: %s cursor index %d of %d lines", ErrInvalidNavigation, name, projection.Cursor.Index, len(projection.Lines))
603+ }
604+ return seen, nil
605+}
606+
607+// ValidatePickerClose requires a nonzero interaction.
608+func ValidatePickerClose(close PickerClose) error {
609+ if close.InteractionID == 0 {
610+ return ErrInvalidNavigation
611+ }
612+ return nil
613+}
614+
615+// ValidatePickerClosed requires a nonzero interaction.
616+func ValidatePickerClosed(closed PickerClosed) error {
617+ if closed.InteractionID == 0 {
618+ return ErrInvalidNavigation
619+ }
620+ return nil
621+}
622+
623+// ValidatePickerSelection enforces nonzero interaction/source revision, a
624+// bounded source identity, a known action, and a known key shape. Unknown
625+// keys, foreign sources, and stale revisions reject at resolve time, not here.
626+func ValidatePickerSelection(selection PickerSelection) error {
627+ if selection.InteractionID == 0 || selection.SourceRevision == 0 {
628+ return ErrInvalidNavigation
629+ }
630+ if !validPickerSourceID(selection.SourceID) || !validPickerAction(selection.Action) {
631+ return ErrInvalidNavigation
632+ }
633+ if !validPickerKey(selection.Key) {
634+ return ErrInvalidNavigation
635+ }
636+ return nil
637+}
638+
639+// ValidatePickerResult enforces matching identity for a completed mutation.
640+func ValidatePickerResult(result PickerResult) error {
641+ if result.InteractionID == 0 || !validPickerSourceID(result.SourceID) || !validPickerAction(result.Action) {
642+ return ErrInvalidNavigation
643+ }
644+ if !validPickerKey(result.Key) {
645+ return ErrInvalidNavigation
646+ }
647+ return nil
648+}
649+
650+// ValidatePickerFailure enforces matching IDs and bounded codes.
651+func ValidatePickerFailure(failure PickerFailure) error {
652+ if failure.InteractionID == 0 || !validPickerAction(failure.Action) {
653+ return ErrInvalidNavigation
654+ }
655+ if failure.SourceID != "" && !validPickerSourceID(failure.SourceID) {
656+ return ErrInvalidNavigation
657+ }
658+ if failure.Key != "" && !validPickerKey(failure.Key) {
659+ return ErrInvalidNavigation
660+ }
661+ if !validPickerFailureCode(failure.Code) {
662+ return ErrInvalidNavigation
663+ }
664+ return nil
665+}
A internal/protocol/picker_interaction_test.go +180 -0
@@ -0,0 +1,180 @@
1+package protocol
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func pickerObserveTargets(seed, count int) []ExactSessionTarget {
11+ targets := make([]ExactSessionTarget, 0, count)
12+ for i := range count {
13+ targets = append(targets, ExactSessionTarget{
14+ LifecycleID: domain.SessionLifecycleID{byte(seed + i)},
15+ SessionName: "work",
16+ })
17+ }
18+ return targets
19+}
20+
21+func TestValidatePickerControlObserveRequest(t *testing.T) {
22+ targets := pickerObserveTargets(1, 2)
23+ target := targets[0]
24+
25+ valid := PickerControlRequest{Version: 1, RequestID: 9, Operation: PickerControlObserve, Targets: targets}
26+ require.NoError(t, ValidatePickerControlRequest(valid))
27+
28+ // The pre-existing exclusive payloads keep working.
29+ require.NoError(t, ValidatePickerControlRequest(PickerControlRequest{Version: 1, RequestID: 9, Operation: PickerControlSnapshot}))
30+ require.NoError(t, ValidatePickerControlRequest(PickerControlRequest{
31+ Version: 1, RequestID: 9, Operation: PickerControlResolve,
32+ SourceRevision: 1, SourceID: PickerHomeSourceID, Key: "a/b",
33+ }))
34+
35+ atLimit := PickerControlRequest{
36+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
37+ Targets: pickerObserveTargets(1, PickerControlMaxTargets),
38+ }
39+ require.NoError(t, ValidatePickerControlRequest(atLimit))
40+
41+ cases := map[string]PickerControlRequest{
42+ "no targets": {Version: 1, RequestID: 9, Operation: PickerControlObserve},
43+ "duplicate target": {
44+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
45+ Targets: []ExactSessionTarget{target, target},
46+ },
47+ "unvalidated target": {
48+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
49+ Targets: []ExactSessionTarget{{SessionName: "work"}},
50+ },
51+ "over the target bound": {
52+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
53+ Targets: pickerObserveTargets(1, PickerControlMaxTargets+1),
54+ },
55+ "observe with source identity": {
56+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
57+ SourceID: PickerHomeSourceID, Targets: targets,
58+ },
59+ "observe with key": {
60+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
61+ Key: "a/b", Targets: targets,
62+ },
63+ "observe with revision": {
64+ Version: 1, RequestID: 9, Operation: PickerControlObserve,
65+ SourceRevision: 2, Targets: targets,
66+ },
67+ "snapshot with targets": {Version: 1, RequestID: 9, Operation: PickerControlSnapshot, Targets: targets},
68+ "resolve with targets": {
69+ Version: 1, RequestID: 9, Operation: PickerControlResolve,
70+ SourceRevision: 1, SourceID: PickerHomeSourceID, Key: "a/b", Targets: targets,
71+ },
72+ "unknown operation": {Version: 1, RequestID: 9, Operation: 9, Targets: targets},
73+ "zero version": {RequestID: 9, Operation: PickerControlObserve, Targets: targets},
74+ "zero request id": {Version: 1, Operation: PickerControlObserve, Targets: targets},
75+ }
76+ for name, request := range cases {
77+ t.Run(name, func(t *testing.T) {
78+ require.ErrorIs(t, ValidatePickerControlRequest(request), ErrInvalidNavigation)
79+ })
80+ }
81+}
82+
83+func TestValidatePickerControlObserveResponse(t *testing.T) {
84+ targets := pickerObserveTargets(1, 2)
85+
86+ valid := PickerControlResponse{
87+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
88+ Observations: []PickerRouteObservation{
89+ {Target: targets[0], Presence: PickerRoutePresent, Attention: true},
90+ {Target: targets[1], Presence: PickerRouteAbsent},
91+ },
92+ }
93+ require.NoError(t, ValidatePickerControlResponse(valid))
94+
95+ // A failed observe carries no payload at all.
96+ require.NoError(t, ValidatePickerControlResponse(PickerControlResponse{
97+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceUnavailable,
98+ }))
99+
100+ atLimit := PickerControlResponse{
101+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
102+ Observations: pickerObservationsForTest(1, PickerControlMaxTargets, PickerRoutePresent),
103+ }
104+ require.NoError(t, ValidatePickerControlResponse(atLimit))
105+
106+ cases := map[string]PickerControlResponse{
107+ "no observations": {
108+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
109+ },
110+ "over the observation bound": {
111+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
112+ Observations: pickerObservationsForTest(1, PickerControlMaxTargets+1, PickerRoutePresent),
113+ },
114+ "duplicate target": {
115+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
116+ Observations: []PickerRouteObservation{
117+ {Target: targets[0], Presence: PickerRoutePresent},
118+ {Target: targets[0], Presence: PickerRouteAbsent},
119+ },
120+ },
121+ "zero presence": {
122+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
123+ Observations: []PickerRouteObservation{{Target: targets[0]}},
124+ },
125+ "unknown presence value": {
126+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
127+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: 9}},
128+ },
129+ "unvalidated target": {
130+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
131+ Observations: []PickerRouteObservation{{Target: ExactSessionTarget{SessionName: "work"}, Presence: PickerRoutePresent}},
132+ },
133+ "attention on absent": {
134+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
135+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRouteAbsent, Attention: true}},
136+ },
137+ "attention on unknown": {
138+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
139+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRouteUnknown, Attention: true}},
140+ },
141+ "observe carries snapshot": {
142+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
143+ Snapshot: &PickerSnapshot{},
144+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}},
145+ },
146+ "observe carries resolved": {
147+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceOK,
148+ Resolved: &AttachTarget{},
149+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}},
150+ },
151+ "failed observe carries observations": {
152+ RequestID: 9, Operation: PickerControlObserve, Status: PickerSourceUnavailable,
153+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}},
154+ },
155+ "snapshot carries observations": {
156+ RequestID: 9, Operation: PickerControlSnapshot, Status: PickerSourceOK,
157+ Snapshot: &PickerSnapshot{},
158+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}},
159+ },
160+ "resolve carries observations": {
161+ RequestID: 9, Operation: PickerControlResolve, Status: PickerSourceOK,
162+ Resolved: &AttachTarget{},
163+ Observations: []PickerRouteObservation{{Target: targets[0], Presence: PickerRoutePresent}},
164+ },
165+ }
166+ for name, response := range cases {
167+ t.Run(name, func(t *testing.T) {
168+ require.ErrorIs(t, ValidatePickerControlResponse(response), ErrInvalidNavigation)
169+ })
170+ }
171+}
172+
173+func pickerObservationsForTest(seed, count int, presence PickerRoutePresence) []PickerRouteObservation {
174+ targets := pickerObserveTargets(seed, count)
175+ observations := make([]PickerRouteObservation, 0, count)
176+ for _, target := range targets {
177+ observations = append(observations, PickerRouteObservation{Target: target, Presence: presence})
178+ }
179+ return observations
180+}
A internal/protocol/picker_preview.go +150 -0
@@ -0,0 +1,150 @@
1+package protocol
2+
3+import (
4+ "errors"
5+ "unicode"
6+ "unicode/utf8"
7+
8+ renderer "github.com/bnema/vev-vt"
9+)
10+
11+// PickerPreviewSchemaVersion versions the picker preview payload independently
12+// from the attachment protocol version.
13+const PickerPreviewSchemaVersion uint16 = 1
14+
15+// PickerPreviewStatus reports whether a preview carries a viewport.
16+type PickerPreviewStatus uint8
17+
18+const (
19+ PickerPreviewOK PickerPreviewStatus = iota
20+ // PickerPreviewNoSuchTarget means the key is no longer part of the
21+ // interaction: the row was retired, replaced, or the revision is stale.
22+ PickerPreviewNoSuchTarget
23+ // PickerPreviewUnavailable means the row exists but its content cannot be
24+ // captured: a stopped session, no renderable tab, or a failed remote fetch.
25+ PickerPreviewUnavailable
26+ PickerPreviewTooLarge
27+)
28+
29+// PickerPreviewRequest asks the serving daemon for the preview of one displayed
30+// row. The client debounces its own cursor movement before sending it; the
31+// daemon registers that row as the viewer's preview source and pushes a new
32+// PickerPreview whenever the row's content changes.
33+type PickerPreviewRequest struct {
34+ Version uint16
35+ InteractionID uint64
36+ SourceID string
37+ Key string
38+ Width uint16
39+ Height uint16
40+}
41+
42+// PickerPreview is the bounded row-major styled-cell viewport of one picker
43+// row. It is never persisted, logged, or traced.
44+type PickerPreview struct {
45+ Version uint16
46+ InteractionID uint64
47+ SourceID string
48+ Key string
49+ Status PickerPreviewStatus
50+ Width uint16
51+ Height uint16
52+ Cells []renderer.Cell
53+}
54+
55+// Preview bounds are shared with the remote preview: both publish the same
56+// bounded viewport shape, only one is fetched from a remote daemon.
57+const (
58+ PickerPreviewMaxWidth = RemotePreviewMaxWidth
59+ PickerPreviewMaxHeight = RemotePreviewMaxHeight
60+ PickerPreviewMaxCells = RemotePreviewMaxCells
61+ PickerPreviewMaxBytes = RemotePreviewMaxBytes
62+)
63+
64+var (
65+ ErrInvalidPickerPreviewRequest = errors.New("vev: invalid picker preview request")
66+ ErrInvalidPickerPreview = errors.New("vev: invalid picker preview")
67+)
68+
69+func ValidatePickerPreviewRequest(request PickerPreviewRequest) error {
70+ if request.Version != PickerPreviewSchemaVersion || request.InteractionID == 0 {
71+ return ErrInvalidPickerPreviewRequest
72+ }
73+ if !validPickerSourceID(request.SourceID) || !validPickerKey(request.Key) {
74+ return ErrInvalidPickerPreviewRequest
75+ }
76+ if request.Width == 0 || request.Height == 0 ||
77+ int(request.Width) > PickerPreviewMaxWidth || int(request.Height) > PickerPreviewMaxHeight {
78+ return ErrInvalidPickerPreviewRequest
79+ }
80+ return nil
81+}
82+
83+func ValidatePickerPreview(preview PickerPreview) error {
84+ if preview.Version != PickerPreviewSchemaVersion || preview.Status > PickerPreviewTooLarge {
85+ return ErrInvalidPickerPreview
86+ }
87+ if preview.InteractionID == 0 || !validPickerSourceID(preview.SourceID) || !validPickerKey(preview.Key) {
88+ return ErrInvalidPickerPreview
89+ }
90+ if preview.Status != PickerPreviewOK {
91+ if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 {
92+ return ErrInvalidPickerPreview
93+ }
94+ return nil
95+ }
96+ return ValidatePickerPreviewViewport(preview.Width, preview.Height, preview.Cells)
97+}
98+
99+// ValidatePickerPreviewViewport checks only the viewport shape, so a capture can
100+// be validated before its interaction identity is stamped onto it.
101+func ValidatePickerPreviewViewport(width, height uint16, cells []renderer.Cell) error {
102+ if width == 0 || height == 0 ||
103+ int(width) > PickerPreviewMaxWidth || int(height) > PickerPreviewMaxHeight {
104+ return ErrInvalidPickerPreview
105+ }
106+ want := int(width) * int(height)
107+ if want > PickerPreviewMaxCells || len(cells) != want {
108+ return ErrInvalidPickerPreview
109+ }
110+ if !previewCellRunValid(int(width), cells) {
111+ return ErrInvalidPickerPreview
112+ }
113+ return nil
114+}
115+
116+func (p PickerPreview) FrameRows() [][]renderer.Cell {
117+ if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) {
118+ return nil
119+ }
120+ rows := make([][]renderer.Cell, p.Height)
121+ width := int(p.Width)
122+ for y := range rows {
123+ rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...)
124+ }
125+ return rows
126+}
127+
128+// previewCellRunValid reports whether cells form the row-major run of a
129+// width-wide viewport: every rune and style is displayable, and each
130+// continuation column follows a double-width cell on the same row.
131+func previewCellRunValid(width int, cells []renderer.Cell) bool {
132+ if width <= 0 {
133+ return false
134+ }
135+ for i, cell := range cells {
136+ if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validPreviewCellStyle(cell.Style) {
137+ return false
138+ }
139+ rowStart := (i / width) * width
140+ rowEnd := rowStart + width
141+ if cell.Continuation {
142+ if cell.Rune != 0 || i == rowStart || cells[i-1].Continuation || renderer.RuneWidth(cells[i-1].Rune) != 2 {
143+ return false
144+ }
145+ } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !cells[i+1].Continuation) {
146+ return false
147+ }
148+ }
149+ return true
150+}
A internal/protocol/picker_preview_test.go +142 -0
@@ -0,0 +1,142 @@
1+package protocol
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+)
8+
9+func pickerPreviewCells(width uint16) []renderer.Cell {
10+ cells := make([]renderer.Cell, width)
11+ for i := range cells {
12+ cells[i] = renderer.Cell{Rune: 'x'}
13+ }
14+ return cells
15+}
16+
17+func TestValidatePickerPreviewRequest(t *testing.T) {
18+ valid := PickerPreviewRequest{
19+ Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
20+ Width: 80, Height: 24,
21+ }
22+ tests := []struct {
23+ name string
24+ mutate func(*PickerPreviewRequest)
25+ wantErr bool
26+ }{
27+ {name: "valid"},
28+ {name: "schema version", mutate: func(r *PickerPreviewRequest) { r.Version = 2 }, wantErr: true},
29+ {name: "zero interaction", mutate: func(r *PickerPreviewRequest) { r.InteractionID = 0 }, wantErr: true},
30+ {name: "empty source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "" }, wantErr: true},
31+ {name: "control source", mutate: func(r *PickerPreviewRequest) { r.SourceID = "serv\x1bing" }, wantErr: true},
32+ {name: "empty key", mutate: func(r *PickerPreviewRequest) { r.Key = "" }, wantErr: true},
33+ {name: "control key", mutate: func(r *PickerPreviewRequest) { r.Key = "aa\x1bfirst" }, wantErr: true},
34+ {name: "zero width", mutate: func(r *PickerPreviewRequest) { r.Width = 0 }, wantErr: true},
35+ {name: "zero height", mutate: func(r *PickerPreviewRequest) { r.Height = 0 }, wantErr: true},
36+ {name: "width over bound", mutate: func(r *PickerPreviewRequest) { r.Width = PickerPreviewMaxWidth + 1 }, wantErr: true},
37+ {name: "height over bound", mutate: func(r *PickerPreviewRequest) { r.Height = PickerPreviewMaxHeight + 1 }, wantErr: true},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ request := valid
42+ if tt.mutate != nil {
43+ tt.mutate(&request)
44+ }
45+ err := ValidatePickerPreviewRequest(request)
46+ if (err != nil) != tt.wantErr {
47+ t.Fatalf("ValidatePickerPreviewRequest() = %v, wantErr %t", err, tt.wantErr)
48+ }
49+ })
50+ }
51+}
52+
53+func TestValidatePickerPreview(t *testing.T) {
54+ valid := PickerPreview{
55+ Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
56+ Status: PickerPreviewOK, Width: 2, Height: 1, Cells: pickerPreviewCells(2),
57+ }
58+ wide := PickerPreview{
59+ Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
60+ Status: PickerPreviewOK,
61+ Width: 2, Height: 1,
62+ Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}},
63+ }
64+ tests := []struct {
65+ name string
66+ preview PickerPreview
67+ wantErr bool
68+ }{
69+ {name: "valid", preview: valid},
70+ {name: "double width continuation", preview: wide},
71+ {name: "schema version", preview: func() PickerPreview { p := valid; p.Version = 2; return p }(), wantErr: true},
72+ {name: "zero interaction", preview: func() PickerPreview { p := valid; p.InteractionID = 0; return p }(), wantErr: true},
73+ {name: "empty key", preview: func() PickerPreview { p := valid; p.Key = ""; return p }(), wantErr: true},
74+ {name: "unknown status", preview: func() PickerPreview { p := valid; p.Status = 99; return p }(), wantErr: true},
75+ {name: "unavailable carries a viewport", preview: func() PickerPreview {
76+ p := valid
77+ p.Status = PickerPreviewUnavailable
78+ return p
79+ }(), wantErr: true},
80+ {name: "unavailable empty", preview: PickerPreview{
81+ Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
82+ Status: PickerPreviewNoSuchTarget,
83+ }},
84+ {name: "zero width", preview: func() PickerPreview {
85+ p := valid
86+ p.Width = 0
87+ return p
88+ }(), wantErr: true},
89+ {name: "cell count mismatch", preview: func() PickerPreview {
90+ p := valid
91+ p.Cells = pickerPreviewCells(1)
92+ return p
93+ }(), wantErr: true},
94+ {name: "control rune", preview: func() PickerPreview {
95+ p := valid
96+ p.Cells = []renderer.Cell{{Rune: '\x1b'}, {Rune: 'x'}}
97+ return p
98+ }(), wantErr: true},
99+ {name: "orphan continuation", preview: func() PickerPreview {
100+ p := valid
101+ p.Cells = []renderer.Cell{{Continuation: true}, {Rune: 'x'}}
102+ return p
103+ }(), wantErr: true},
104+ {name: "unpaired double width", preview: func() PickerPreview {
105+ p := valid
106+ p.Cells = []renderer.Cell{{Rune: '界'}, {Rune: 'x'}}
107+ return p
108+ }(), wantErr: true},
109+ {name: "unsupported style", preview: func() PickerPreview {
110+ p := valid
111+ p.Cells = []renderer.Cell{{Rune: 'x', Style: renderer.Style{Attrs: 1 << 7, UnderlineStyle: renderer.UnderlineDashed + 1}}, {Rune: 'x'}}
112+ return p
113+ }(), wantErr: true},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ err := ValidatePickerPreview(tt.preview)
118+ if (err != nil) != tt.wantErr {
119+ t.Fatalf("ValidatePickerPreview() = %v, wantErr %t", err, tt.wantErr)
120+ }
121+ })
122+ }
123+}
124+
125+func TestPickerPreviewFrameRows(t *testing.T) {
126+ preview := PickerPreview{
127+ Version: PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
128+ Status: PickerPreviewOK, Width: 2, Height: 2, Cells: pickerPreviewCells(4),
129+ }
130+ rows := preview.FrameRows()
131+ if len(rows) != 2 || len(rows[0]) != 2 || rows[0][0].Rune != 'x' {
132+ t.Fatalf("FrameRows() = %+v, want two 2-cell rows", rows)
133+ }
134+ rows[0][0].Rune = 'y'
135+ if preview.Cells[0].Rune != 'x' {
136+ t.Fatal("FrameRows must copy the cells, not alias them")
137+ }
138+ empty := PickerPreview{Version: PickerPreviewSchemaVersion, Status: PickerPreviewUnavailable}
139+ if rows := empty.FrameRows(); rows != nil {
140+ t.Fatalf("FrameRows() without a viewport = %+v, want nil", rows)
141+ }
142+}
M internal/protocol/preview.go +3 -17
@@ -3,8 +3,6 @@ package protocol
3 import (3 import (
4 "errors"4 "errors"
5 "math"5 "math"
6- "unicode"
7- "unicode/utf8"
8 6
9 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -97,25 +95,13 @@ func ValidateRemotePreview(preview RemotePreview) error {
97 if want > RemotePreviewMaxCells || len(preview.Cells) != want {95 if want > RemotePreviewMaxCells || len(preview.Cells) != want {
98 return ErrRemotePreviewTooLarge96 return ErrRemotePreviewTooLarge
99 }97 }
100- width := int(preview.Width)98+ if !previewCellRunValid(int(preview.Width), preview.Cells) {
101- for i, cell := range preview.Cells {99+ return ErrInvalidRemotePreview
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- }
114 }100 }
115 return nil101 return nil
116 }102 }
117 103
118-func validRemotePreviewStyle(style renderer.Style) bool {104+func validPreviewCellStyle(style renderer.Style) bool {
119 return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 &&105 return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 &&
120 style.UnderlineStyle <= renderer.UnderlineDashed &&106 style.UnderlineStyle <= renderer.UnderlineDashed &&
121 style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 &&107 style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 &&
M internal/protocol/session.go +2 -3
@@ -13,7 +13,7 @@ import (
13 )13 )
14 14
15 // Version is the negotiated vev session protocol version.15 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 4316+const Version uint16 = 51
17 17
18 // HandshakeTimeout bounds every transport handshake from connect through the18 // HandshakeTimeout bounds every transport handshake from connect through the
19 // first committed publication.19 // first committed publication.
@@ -113,7 +113,6 @@ type Hello struct {
113 PreferredTabID domain.TabStableID113 PreferredTabID domain.TabStableID
114 EnvironmentPolicy EnvironmentPolicy114 EnvironmentPolicy EnvironmentPolicy
115 NavigationCapabilities NavigationCapabilities115 NavigationCapabilities NavigationCapabilities
116- StartupOverlay StartupOverlay
117 Remote bool116 Remote bool
118 }117 }
119 118
@@ -368,7 +367,7 @@ func ValidateHello(h Hello) error {
368 if !validEnvironmentPolicy(h.EnvironmentPolicy) {367 if !validEnvironmentPolicy(h.EnvironmentPolicy) {
369 return ErrInvalidHello368 return ErrInvalidHello
370 }369 }
371- if err := validateHelloNavigation(h); err != nil {370+ if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
372 return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)371 return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
373 }372 }
374 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {373 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
M internal/protocol/session_test.go +2 -3
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(43), Version)13+ require.Equal(t, uint16(51), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
@@ -24,7 +24,7 @@ func TestHelloSemanticValidation(t *testing.T) {
24 {name: "valid", mutate: func(*Hello) {}},24 {name: "valid", mutate: func(*Hello) {}},
25 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},25 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},
26 {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello},26 {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello},
27- {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = NavigationCapabilityBack }, want: ErrInvalidHello},27+ {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello},
28 {name: "exact target name mismatch", mutate: func(h *Hello) {28 {name: "exact target name mismatch", mutate: func(h *Hello) {
29 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}29 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}
30 }, want: ErrInvalidHello},30 }, want: ErrInvalidHello},
@@ -85,5 +85,4 @@ func TestRouteAndPreviewSemanticValidation(t *testing.T) {
85 func TestSemanticErrorsPreserveClassification(t *testing.T) {85 func TestSemanticErrorsPreserveClassification(t *testing.T) {
86 require.True(t, errors.Is(ValidateAck(Ack{}), ErrInvalidAck))86 require.True(t, errors.Is(ValidateAck(Ack{}), ErrInvalidAck))
87 require.True(t, errors.Is(ValidateAttachTarget(AttachTarget{}), ErrInvalidAttachTarget))87 require.True(t, errors.Is(ValidateAttachTarget(AttachTarget{}), ErrInvalidAttachTarget))
88- require.True(t, errors.Is(ValidateParkedRouteRequest(ParkedRouteRequest{}), ErrInvalidNavigation))
89 }88 }
M internal/protocol/wire/frame.go +17 -5
@@ -9,8 +9,11 @@ type MsgType uint8
9 9
10 // Frame message types use non-contiguous allocations: the legacy client range10 // Frame message types use non-contiguous allocations: the legacy client range
11 // is 1–13 and 15, and newer client controls use 32–33, 35, 37, 39, 42, 45,11 // is 1–13 and 15, and newer client controls use 32–33, 35, 37, 39, 42, 45,
12-// and 47. Server-originated messages occupy 16–23, 25–31, 34, 36, 38, 40–41,12+// 47, 49, 51, 53, 55, and 57 (49+ carry the client-picker interaction: begin,
13-// and 43–44, 46, and 48. Values 14 and 24 remain reserved for future extensions.13+// close, selection, preview request). Server-originated messages occupy 16–23,
14+// 25–31, 34, 36, 38, 40–41, 43–44, 46, 48, 50, 52, 54, 56, 58, and 59 (50+
15+// carry the client-picker interaction: offer, snapshot, closed, failure,
16+// result, preview). Values 14 and 24 remain reserved for future extensions.
14 const (17 const (
15 MsgHello MsgType = 118 MsgHello MsgType = 1
16 MsgInput MsgType = 219 MsgInput MsgType = 2
@@ -28,12 +31,16 @@ const (
28 MsgRemotePreviewRequest MsgType = 1531 MsgRemotePreviewRequest MsgType = 15
29 MsgRouteAttentionSubscription MsgType = 3232 MsgRouteAttentionSubscription MsgType = 32
30 MsgSamePeerSwitchRequest MsgType = 3333 MsgSamePeerSwitchRequest MsgType = 33
31- MsgParkedRouteRequest MsgType = 35
32 MsgSessionCreationFailure MsgType = 3734 MsgSessionCreationFailure MsgType = 37
33 MsgUIFence MsgType = 3935 MsgUIFence MsgType = 39
34 MsgNavigationInventoryRequest MsgType = 4236 MsgNavigationInventoryRequest MsgType = 42
35 MsgNavigationInventoryPublication MsgType = 4537 MsgNavigationInventoryPublication MsgType = 45
36 MsgNavigationInventoryFailure MsgType = 4738 MsgNavigationInventoryFailure MsgType = 47
39+ MsgPickerCloseClient MsgType = 51
40+ MsgPickerSelection MsgType = 53
41+ MsgPickerBegin MsgType = 55
42+ MsgPickerPreviewRequest MsgType = 57
43+ MsgPickerControlRequest MsgType = 60
37 44
38 MsgWelcome MsgType = 1645 MsgWelcome MsgType = 16
39 MsgError MsgType = 1746 MsgError MsgType = 17
@@ -42,7 +49,6 @@ const (
42 MsgPong MsgType = 2049 MsgPong MsgType = 20
43 MsgSessions MsgType = 2150 MsgSessions MsgType = 21
44 MsgCommandResult MsgType = 2251 MsgCommandResult MsgType = 22
45- MsgNavigationAction MsgType = 23
46 MsgAttachTarget MsgType = 2552 MsgAttachTarget MsgType = 25
47 MsgRemotePreviewResponse MsgType = 2653 MsgRemotePreviewResponse MsgType = 26
48 MsgCommittedRouteIdentity MsgType = 2754 MsgCommittedRouteIdentity MsgType = 27
@@ -51,7 +57,6 @@ const (
51 MsgRouteNavigationFailure MsgType = 3057 MsgRouteNavigationFailure MsgType = 30
52 MsgRoutePosition MsgType = 3158 MsgRoutePosition MsgType = 31
53 MsgSamePeerSwitchFailure MsgType = 3459 MsgSamePeerSwitchFailure MsgType = 34
54- MsgParkedRouteResponse MsgType = 36
55 MsgRouteCreateSession MsgType = 3860 MsgRouteCreateSession MsgType = 38
56 MsgUIReceipt MsgType = 4061 MsgUIReceipt MsgType = 40
57 MsgUIViewUpdate MsgType = 4162 MsgUIViewUpdate MsgType = 41
@@ -59,6 +64,13 @@ const (
59 MsgNavigationInventoryDemand MsgType = 4464 MsgNavigationInventoryDemand MsgType = 44
60 MsgNavigationInventorySelection MsgType = 4665 MsgNavigationInventorySelection MsgType = 46
61 MsgRouteRetired MsgType = 4866 MsgRouteRetired MsgType = 48
67+ MsgPickerSnapshot MsgType = 50
68+ MsgPickerClosedServer MsgType = 52
69+ MsgPickerFailure MsgType = 54
70+ MsgPickerOffer MsgType = 56
71+ MsgPickerResult MsgType = 58
72+ MsgPickerPreview MsgType = 59
73+ MsgPickerControlResponse MsgType = 61
62 )74 )
63 75
64 // Frame is the unit of exchange over a Transport: a typed, length-delimited76 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/protocol/wire/frame_test.go +10 -8
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 func TestProtocolVersion(t *testing.T) {9 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 43 {10+ if protocol.Version != 51 {
11- t.Fatalf("ProtocolVersion = %d, want 43", protocol.Version)11+ t.Fatalf("ProtocolVersion = %d, want 51", protocol.Version)
12 }12 }
13 }13 }
14 14
@@ -25,9 +25,6 @@ func TestControlMsgTypes(t *testing.T) {
25 }25 }
26 26
27 func TestMsgTypeUnique(t *testing.T) {27 func TestMsgTypeUnique(t *testing.T) {
28- if MsgNavigationAction != 23 {
29- t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction)
30- }
31 tests := []struct {28 tests := []struct {
32 name string29 name string
33 typ MsgType30 typ MsgType
@@ -37,14 +34,19 @@ func TestMsgTypeUnique(t *testing.T) {
37 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},34 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
38 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},35 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
39 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},36 {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
40- {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgParkedRouteRequest", MsgParkedRouteRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},37+ {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, {"MsgSamePeerSwitchRequest", MsgSamePeerSwitchRequest}, {"MsgSessionCreationFailure", MsgSessionCreationFailure},
41 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},38 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
42 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},39 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
43 {"MsgCommandResult", MsgCommandResult},40 {"MsgCommandResult", MsgCommandResult},
44- {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},41+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},
45 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},42 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
46 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},43 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
47- {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgParkedRouteResponse", MsgParkedRouteResponse}, {"MsgRouteCreateSession", MsgRouteCreateSession},44+ {"MsgRoutePosition", MsgRoutePosition}, {"MsgSamePeerSwitchFailure", MsgSamePeerSwitchFailure}, {"MsgRouteCreateSession", MsgRouteCreateSession},
45+ {"MsgPickerOffer", MsgPickerOffer}, {"MsgPickerSnapshot", MsgPickerSnapshot}, {"MsgPickerClosedServer", MsgPickerClosedServer},
46+ {"MsgPickerFailure", MsgPickerFailure}, {"MsgPickerResult", MsgPickerResult}, {"MsgPickerPreview", MsgPickerPreview},
47+ {"MsgPickerBegin", MsgPickerBegin}, {"MsgPickerCloseClient", MsgPickerCloseClient}, {"MsgPickerSelection", MsgPickerSelection},
48+ {"MsgPickerPreviewRequest", MsgPickerPreviewRequest},
49+ {"MsgPickerControlRequest", MsgPickerControlRequest}, {"MsgPickerControlResponse", MsgPickerControlResponse},
48 }50 }
49 51
50 seen := make(map[MsgType]string, len(tests))52 seen := make(map[MsgType]string, len(tests))
M internal/protocol/wire/navigation_test.go +8 -148
@@ -1,7 +1,6 @@
1 package wire1 package wire
2 2
3 import (3 import (
4- "encoding/hex"
5 "testing"4 "testing"
6 5
7 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
@@ -9,129 +8,6 @@ import (
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
10 )9 )
11 10
12-func TestNavigationDirectiveWireTable(t *testing.T) {
13- lease := protocol.ParkedRouteLeaseID{1, 2, 3}
14- tests := []struct {
15- name string
16- directive protocol.NavigationDirective
17- valid bool
18- }{
19- {name: "open home", directive: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: lease}, valid: true},
20- {name: "back", directive: protocol.NavigationDirective{Action: protocol.NavigationBack}, valid: true},
21- {name: "open home without lease", directive: protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker}},
22- {name: "back with lease", directive: protocol.NavigationDirective{Action: protocol.NavigationBack, LeaseID: lease}},
23- {name: "zero", directive: protocol.NavigationDirective{}},
24- {name: "unknown", directive: protocol.NavigationDirective{Action: 3}},
25- }
26-
27- for _, tt := range tests {
28- t.Run(tt.name, func(t *testing.T) {
29- got := MarshalNavigationDirective(tt.directive)
30- if !tt.valid {
31- require.Nil(t, got)
32- return
33- }
34- decoded, err := UnmarshalNavigationDirective(got)
35- require.NoError(t, err)
36- require.Equal(t, tt.directive, decoded)
37- assertAllPrefixesFail(t, got, UnmarshalNavigationDirective)
38- _, err = UnmarshalNavigationDirective(append(append([]byte(nil), got...), 0))
39- require.ErrorIs(t, err, protocol.ErrInvalidNavigation)
40- })
41- }
42-}
43-
44-func TestParkedRouteWireRoundTripsStrictly(t *testing.T) {
45- lease := protocol.ParkedRouteLeaseID{1, 2, 3}
46- target := &domain.RemoteSessionTarget{
47- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{4},
48- SessionName: "work", LiveTabID: "tab-1",
49- }
50- requests := []struct {
51- name string
52- value protocol.ParkedRouteRequest
53- golden string
54- }{
55- {name: "prepare", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare}, golden: "0000000000000001" + "01020300000000000000000000000000" + "010001"},
56- {name: "resume", value: protocol.ParkedRouteRequest{RequestID: 2, LeaseID: lease, Action: protocol.ParkedRouteResume}},
57- {name: "switch", value: protocol.ParkedRouteRequest{RequestID: 3, LeaseID: lease, Action: protocol.ParkedRouteSwitch, Target: target}},
58- }
59- for _, tt := range requests {
60- t.Run("request "+tt.name, func(t *testing.T) {
61- payload := MarshalParkedRouteRequest(tt.value)
62- require.NotNil(t, payload)
63- if tt.golden != "" {
64- golden, err := hex.DecodeString(tt.golden)
65- require.NoError(t, err)
66- require.Equal(t, golden, payload)
67- }
68- decoded, err := UnmarshalParkedRouteRequest(payload)
69- require.NoError(t, err)
70- require.Equal(t, tt.value, decoded)
71- assertAllPrefixesFail(t, payload, UnmarshalParkedRouteRequest)
72- assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteRequest)
73- })
74- }
75-
76- responses := []struct {
77- name string
78- value protocol.ParkedRouteResponse
79- golden string
80- }{
81- {name: "ready", value: protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}, golden: "000000000000000101"},
82- {name: "resumed", value: protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed}},
83- {name: "switched", value: protocol.ParkedRouteResponse{RequestID: 3, Status: protocol.ParkedRouteSwitched}},
84- {name: "rejected", value: protocol.ParkedRouteResponse{RequestID: 4, Status: protocol.ParkedRouteRejected}},
85- {name: "expired", value: protocol.ParkedRouteResponse{RequestID: 5, Status: protocol.ParkedRouteExpired}},
86- {name: "stale target", value: protocol.ParkedRouteResponse{RequestID: 6, Status: protocol.ParkedRouteStaleTarget}},
87- }
88- for _, tt := range responses {
89- t.Run("response "+tt.name, func(t *testing.T) {
90- payload := MarshalParkedRouteResponse(tt.value)
91- if tt.golden != "" {
92- golden, err := hex.DecodeString(tt.golden)
93- require.NoError(t, err)
94- require.Equal(t, golden, payload)
95- }
96- decoded, err := UnmarshalParkedRouteResponse(payload)
97- require.NoError(t, err)
98- require.Equal(t, tt.value, decoded)
99- assertAllPrefixesFail(t, payload, UnmarshalParkedRouteResponse)
100- assertTrailingGarbageFails(t, payload, UnmarshalParkedRouteResponse)
101- })
102- }
103-
104- invalidRequests := []struct {
105- name string
106- value protocol.ParkedRouteRequest
107- }{
108- {name: "zero request ID", value: protocol.ParkedRouteRequest{LeaseID: lease, Action: protocol.ParkedRoutePrepare}},
109- {name: "zero lease", value: protocol.ParkedRouteRequest{RequestID: 1, Action: protocol.ParkedRoutePrepare}},
110- {name: "unknown action", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: 99}},
111- {name: "prepare target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRoutePrepare, Target: target}},
112- {name: "resume target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteResume, Target: target}},
113- {name: "missing switch target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteSwitch}},
114- {name: "invalid switch target", value: protocol.ParkedRouteRequest{RequestID: 1, LeaseID: lease, Action: protocol.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}},
115- }
116- for _, tt := range invalidRequests {
117- t.Run("invalid request "+tt.name, func(t *testing.T) {
118- require.Nil(t, MarshalParkedRouteRequest(tt.value))
119- })
120- }
121- for _, tt := range []struct {
122- name string
123- value protocol.ParkedRouteResponse
124- }{
125- {name: "zero request ID", value: protocol.ParkedRouteResponse{Status: protocol.ParkedRouteReady}},
126- {name: "zero status", value: protocol.ParkedRouteResponse{RequestID: 1}},
127- {name: "unknown status", value: protocol.ParkedRouteResponse{RequestID: 1, Status: 99}},
128- } {
129- t.Run("invalid response "+tt.name, func(t *testing.T) {
130- require.Nil(t, MarshalParkedRouteResponse(tt.value))
131- })
132- }
133-}
134-
135 func TestHelloNavigationValidationTable(t *testing.T) {11 func TestHelloNavigationValidationTable(t *testing.T) {
136 base := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}12 base := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
137 remoteTarget := &domain.RemoteSessionTarget{13 remoteTarget := &domain.RemoteSessionTarget{
@@ -139,26 +15,18 @@ func TestHelloNavigationValidationTable(t *testing.T) {
139 SessionName: "work", LiveTabID: "tab-1",15 SessionName: "work", LiveTabID: "tab-1",
140 }16 }
141 tests := []struct {17 tests := []struct {
142- name string18+ name string
143- hello protocol.Hello19+ hello protocol.Hello
144- valid bool20+ valid bool
145- capability protocol.NavigationCapabilities21+ capability protocol.NavigationCapabilities
146- overlay protocol.StartupOverlay
147- wantPayload string
148 }{22 }{
149 {name: "ordinary", hello: base, valid: true},23 {name: "ordinary", hello: base, valid: true},
150 {name: "resume route", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},24 {name: "resume route", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},
151- {name: "daemon-owned home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: true, capability: protocol.NavigationCapabilityHomePicker},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},
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},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},
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},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},
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},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},
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},
159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 8}, valid: false},29 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 8}, valid: false},
160- {name: "back without picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack}, valid: false},
161- {name: "picker without back", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
162 }30 }
163 31
164 for _, tt := range tests {32 for _, tt := range tests {
@@ -168,17 +36,9 @@ func TestHelloNavigationValidationTable(t *testing.T) {
168 require.NoError(t, err)36 require.NoError(t, err)
169 payload := MarshalHello(tt.hello)37 payload := MarshalHello(tt.hello)
170 require.NotNil(t, payload)38 require.NotNil(t, payload)
171- if tt.wantPayload != "" {
172- expected, decodeErr := hex.DecodeString(tt.wantPayload)
173- require.NoError(t, decodeErr)
174- require.Equal(t, expected, payload)
175- assertAllPrefixesFail(t, expected, UnmarshalHello)
176- assertTrailingGarbageFails(t, expected, UnmarshalHello)
177- }
178 decoded, decodeErr := UnmarshalHello(payload)39 decoded, decodeErr := UnmarshalHello(payload)
179 require.NoError(t, decodeErr)40 require.NoError(t, decodeErr)
180 require.Equal(t, tt.capability, decoded.NavigationCapabilities)41 require.Equal(t, tt.capability, decoded.NavigationCapabilities)
181- require.Equal(t, tt.overlay, decoded.StartupOverlay)
182 return42 return
183 }43 }
184 require.Error(t, err)44 require.Error(t, err)
A internal/protocol/wire/picker_control.go +249 -0
@@ -0,0 +1,249 @@
1+package wire
2+
3+import (
4+ "encoding/binary"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+func PeekPickerControlVersion(data []byte) (uint16, bool) {
10+ if len(data) < 2 {
11+ return 0, false
12+ }
13+ return binary.BigEndian.Uint16(data), true
14+}
15+
16+// marshalPickerControlTargets encodes a bounded exact-target list. The count
17+// guard mirrors semantic validation so a malformed caller can never write an
18+// unbounded frame.
19+func marshalPickerControlTargets(w *payloadWriter, targets []protocol.ExactSessionTarget) bool {
20+ if len(targets) > protocol.PickerControlMaxTargets {
21+ return false
22+ }
23+ w.putUint16(uint16(len(targets)))
24+ for _, target := range targets {
25+ marshalExactSessionTarget(w, target)
26+ }
27+ return true
28+}
29+
30+func unmarshalPickerControlTargets(r *payloadReader) ([]protocol.ExactSessionTarget, error) {
31+ count, err := r.getUint16()
32+ if err != nil {
33+ return nil, err
34+ }
35+ if int(count) > protocol.PickerControlMaxTargets {
36+ return nil, protocol.ErrInvalidNavigation
37+ }
38+ if count == 0 {
39+ return nil, nil
40+ }
41+ targets := make([]protocol.ExactSessionTarget, 0, int(count))
42+ for range int(count) {
43+ target, err := unmarshalExactSessionTarget(r)
44+ if err != nil {
45+ return nil, err
46+ }
47+ targets = append(targets, target)
48+ }
49+ return targets, nil
50+}
51+
52+// marshalPickerRouteObservations encodes the observation payload carried inside
53+// the response union. The exact-target helper keeps the target layout shared
54+// with the rest of the wire.
55+func marshalPickerRouteObservations(w *payloadWriter, observations []protocol.PickerRouteObservation) bool {
56+ if len(observations) > protocol.PickerControlMaxTargets {
57+ return false
58+ }
59+ w.putUint16(uint16(len(observations)))
60+ for _, observation := range observations {
61+ marshalExactSessionTarget(w, observation.Target)
62+ w.putUint8(uint8(observation.Presence))
63+ w.putBool(observation.Attention)
64+ }
65+ return true
66+}
67+
68+// unmarshalPickerRouteObservations decodes the observation payload carried
69+// inside the response union and rejects an overlong count or trailing bytes.
70+// Semantic checks stay with ValidatePickerControlResponse, which runs after the
71+// complete response is decoded.
72+func unmarshalPickerRouteObservations(data []byte) ([]protocol.PickerRouteObservation, error) {
73+ r := payloadReader{b: data}
74+ count, err := r.getUint16()
75+ if err != nil {
76+ return nil, err
77+ }
78+ if int(count) > protocol.PickerControlMaxTargets {
79+ return nil, protocol.ErrInvalidNavigation
80+ }
81+ if count == 0 {
82+ return nil, nil
83+ }
84+ observations := make([]protocol.PickerRouteObservation, 0, int(count))
85+ for range int(count) {
86+ var observation protocol.PickerRouteObservation
87+ target, err := unmarshalExactSessionTarget(&r)
88+ if err != nil {
89+ return nil, err
90+ }
91+ observation.Target = target
92+ presence, err := r.getUint8()
93+ if err != nil {
94+ return nil, err
95+ }
96+ observation.Presence = protocol.PickerRoutePresence(presence)
97+ if observation.Attention, err = r.getBool(); err != nil {
98+ return nil, err
99+ }
100+ observations = append(observations, observation)
101+ }
102+ if err := r.done(); err != nil {
103+ return nil, err
104+ }
105+ return observations, nil
106+}
107+
108+func MarshalPickerControlRequest(request protocol.PickerControlRequest) []byte {
109+ if protocol.ValidatePickerControlRequest(request) != nil {
110+ return nil
111+ }
112+ w := payloadWriter{}
113+ w.putUint16(request.Version)
114+ w.putUint64(request.RequestID)
115+ w.putUint8(uint8(request.Operation))
116+ w.putUint64(request.SourceRevision)
117+ w.putString(request.SourceID)
118+ w.putString(request.Key)
119+ if !marshalPickerControlTargets(&w, request.Targets) {
120+ return nil
121+ }
122+ return w.b
123+}
124+
125+func UnmarshalPickerControlRequest(data []byte) (protocol.PickerControlRequest, error) {
126+ r := payloadReader{b: data}
127+ var request protocol.PickerControlRequest
128+ var err error
129+ if request.Version, err = r.getUint16(); err != nil {
130+ return request, err
131+ }
132+ if request.RequestID, err = r.getUint64(); err != nil {
133+ return request, err
134+ }
135+ op, err := r.getUint8()
136+ if err != nil {
137+ return request, err
138+ }
139+ request.Operation = protocol.PickerControlOperation(op)
140+ if request.SourceRevision, err = r.getUint64(); err != nil {
141+ return request, err
142+ }
143+ if request.SourceID, err = r.getString(); err != nil {
144+ return request, err
145+ }
146+ if request.Key, err = r.getString(); err != nil {
147+ return request, err
148+ }
149+ if request.Targets, err = unmarshalPickerControlTargets(&r); err != nil {
150+ return request, err
151+ }
152+ if err := r.done(); err != nil {
153+ return request, err
154+ }
155+ if err := protocol.ValidatePickerControlRequest(request); err != nil {
156+ return request, err
157+ }
158+ return request, nil
159+}
160+
161+func MarshalPickerControlResponse(response protocol.PickerControlResponse) []byte {
162+ if protocol.ValidatePickerControlResponse(response) != nil {
163+ return nil
164+ }
165+ w := payloadWriter{}
166+ w.putUint64(response.RequestID)
167+ w.putUint8(uint8(response.Operation))
168+ w.putUint8(uint8(response.Status))
169+ if response.Snapshot != nil {
170+ w.putLongBytes(MarshalPickerSnapshot(*response.Snapshot))
171+ } else {
172+ w.putLongBytes(nil)
173+ }
174+ if response.Resolved != nil {
175+ payload := MarshalAttachTarget(*response.Resolved)
176+ w.putLongBytes(payload)
177+ } else {
178+ w.putLongBytes(nil)
179+ }
180+ if len(response.Observations) != 0 {
181+ inner := payloadWriter{}
182+ if !marshalPickerRouteObservations(&inner, response.Observations) {
183+ return nil
184+ }
185+ w.putLongBytes(inner.b)
186+ } else {
187+ w.putLongBytes(nil)
188+ }
189+ return w.b
190+}
191+
192+func UnmarshalPickerControlResponse(data []byte) (protocol.PickerControlResponse, error) {
193+ r := payloadReader{b: data}
194+ var response protocol.PickerControlResponse
195+ var err error
196+ if response.RequestID, err = r.getUint64(); err != nil {
197+ return response, err
198+ }
199+ op, err := r.getUint8()
200+ if err != nil {
201+ return response, err
202+ }
203+ response.Operation = protocol.PickerControlOperation(op)
204+ status, err := r.getUint8()
205+ if err != nil {
206+ return response, err
207+ }
208+ response.Status = protocol.PickerSourceStatus(status)
209+ snapshotData, err := r.getLongBytes()
210+ if err != nil {
211+ return response, err
212+ }
213+ if len(snapshotData) != 0 {
214+ snapshot, err := UnmarshalPickerSnapshot(snapshotData)
215+ if err != nil {
216+ return response, err
217+ }
218+ response.Snapshot = &snapshot
219+ }
220+ targetData, err := r.getLongBytes()
221+ if err != nil {
222+ return response, err
223+ }
224+ if len(targetData) != 0 {
225+ target, err := UnmarshalAttachTarget(targetData)
226+ if err != nil {
227+ return response, err
228+ }
229+ response.Resolved = &target
230+ }
231+ observationsData, err := r.getLongBytes()
232+ if err != nil {
233+ return response, err
234+ }
235+ if len(observationsData) != 0 {
236+ observations, err := unmarshalPickerRouteObservations(observationsData)
237+ if err != nil {
238+ return response, err
239+ }
240+ response.Observations = observations
241+ }
242+ if err := r.done(); err != nil {
243+ return response, err
244+ }
245+ if err := protocol.ValidatePickerControlResponse(response); err != nil {
246+ return response, err
247+ }
248+ return response, nil
249+}
A internal/protocol/wire/picker_control_test.go +150 -0
@@ -0,0 +1,150 @@
1+package wire
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPickerControlRequestRoundTripStrict(t *testing.T) {
12+ request := protocol.PickerControlRequest{Version: protocol.Version, RequestID: 7, Operation: protocol.PickerControlResolve, SourceRevision: 3, SourceID: protocol.PickerHomeSourceID, Key: "a/b"}
13+ payload := MarshalPickerControlRequest(request)
14+ require.NotNil(t, payload)
15+ decoded, err := UnmarshalPickerControlRequest(payload)
16+ require.NoError(t, err)
17+ require.Equal(t, request, decoded)
18+ assertAllPrefixesFail(t, payload, UnmarshalPickerControlRequest)
19+ assertTrailingGarbageFails(t, payload, UnmarshalPickerControlRequest)
20+}
21+
22+func TestPickerControlResponseRoundTripStrict(t *testing.T) {
23+ snapshot := pickerSnapshotForTest()
24+ snapshot.SourceID = protocol.PickerHomeSourceID
25+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
26+ response := protocol.PickerControlResponse{RequestID: 8, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot}
27+ payload := MarshalPickerControlResponse(response)
28+ require.NotNil(t, payload)
29+ decoded, err := UnmarshalPickerControlResponse(payload)
30+ require.NoError(t, err)
31+ require.Equal(t, response, decoded)
32+ assertAllPrefixesFail(t, payload, UnmarshalPickerControlResponse)
33+ assertTrailingGarbageFails(t, payload, UnmarshalPickerControlResponse)
34+}
35+
36+func pickerObserveTargetsForTest(n int) []protocol.ExactSessionTarget {
37+ targets := make([]protocol.ExactSessionTarget, 0, n)
38+ for i := range n {
39+ targets = append(targets, protocol.ExactSessionTarget{
40+ LifecycleID: domain.SessionLifecycleID{byte(i + 1)},
41+ SessionName: "work",
42+ })
43+ }
44+ return targets
45+}
46+
47+func TestPickerControlObserveRequestRoundTripStrict(t *testing.T) {
48+ request := protocol.PickerControlRequest{
49+ Version: protocol.Version,
50+ RequestID: 11,
51+ Operation: protocol.PickerControlObserve,
52+ Targets: pickerObserveTargetsForTest(2),
53+ }
54+ payload := MarshalPickerControlRequest(request)
55+ require.NotNil(t, payload)
56+ decoded, err := UnmarshalPickerControlRequest(payload)
57+ require.NoError(t, err)
58+ require.Equal(t, request, decoded)
59+ assertAllPrefixesFail(t, payload, UnmarshalPickerControlRequest)
60+ assertTrailingGarbageFails(t, payload, UnmarshalPickerControlRequest)
61+}
62+
63+func TestPickerControlObserveResponseRoundTripStrict(t *testing.T) {
64+ targets := pickerObserveTargetsForTest(3)
65+ response := protocol.PickerControlResponse{
66+ RequestID: 11,
67+ Operation: protocol.PickerControlObserve,
68+ Status: protocol.PickerSourceOK,
69+ Observations: []protocol.PickerRouteObservation{
70+ {Target: targets[0], Presence: protocol.PickerRoutePresent, Attention: true},
71+ {Target: targets[1], Presence: protocol.PickerRouteAbsent},
72+ {Target: targets[2], Presence: protocol.PickerRouteUnknown},
73+ },
74+ }
75+ payload := MarshalPickerControlResponse(response)
76+ require.NotNil(t, payload)
77+ decoded, err := UnmarshalPickerControlResponse(payload)
78+ require.NoError(t, err)
79+ require.Equal(t, response, decoded)
80+ assertAllPrefixesFail(t, payload, UnmarshalPickerControlResponse)
81+ assertTrailingGarbageFails(t, payload, UnmarshalPickerControlResponse)
82+}
83+
84+func TestPickerControlRejectsExclusivePayloadViolations(t *testing.T) {
85+ targets := pickerObserveTargetsForTest(1)
86+ // Inspect and resolve never carry targets; observe never carries source
87+ // identity.
88+ require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{
89+ Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlSnapshot, Targets: targets,
90+ }))
91+ require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{
92+ Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlResolve, SourceRevision: 1,
93+ SourceID: protocol.PickerHomeSourceID, Key: "a/b", Targets: targets,
94+ }))
95+ require.Nil(t, MarshalPickerControlRequest(protocol.PickerControlRequest{
96+ Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve,
97+ SourceID: protocol.PickerHomeSourceID, Targets: targets,
98+ }))
99+ snapshot := pickerSnapshotForTest()
100+ snapshot.SourceID = protocol.PickerHomeSourceID
101+ // A snapshot response never carries observations.
102+ require.Nil(t, MarshalPickerControlResponse(protocol.PickerControlResponse{
103+ RequestID: 1, Operation: protocol.PickerControlSnapshot, Status: protocol.PickerSourceOK, Snapshot: &snapshot,
104+ Observations: []protocol.PickerRouteObservation{{Target: targets[0], Presence: protocol.PickerRoutePresent}},
105+ }))
106+}
107+
108+func pickerObserveResponseEnvelope(observations []byte) []byte {
109+ w := payloadWriter{}
110+ w.putUint64(1)
111+ w.putUint8(uint8(protocol.PickerControlObserve))
112+ w.putUint8(uint8(protocol.PickerSourceOK))
113+ w.putLongBytes(nil)
114+ w.putLongBytes(nil)
115+ w.putLongBytes(observations)
116+ return w.b
117+}
118+
119+func TestPickerControlObserveWireRejectsUnboundedOrEmptyPayloads(t *testing.T) {
120+ // An observe request claiming more targets than the bound fails during
121+ // decoding, before semantic validation ever runs.
122+ request := payloadWriter{}
123+ request.putUint16(protocol.Version)
124+ request.putUint64(1)
125+ request.putUint8(uint8(protocol.PickerControlObserve))
126+ request.putUint64(0)
127+ request.putString("")
128+ request.putString("")
129+ request.putUint16(protocol.PickerControlMaxTargets + 1)
130+ _, err := UnmarshalPickerControlRequest(request.b)
131+ require.Error(t, err)
132+
133+ // A missing observation payload cannot answer an observe request.
134+ _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(nil))
135+ require.Error(t, err)
136+
137+ // A present but empty observation payload is rejected: the exclusive union
138+ // member is set and must carry at least one observation.
139+ empty := payloadWriter{}
140+ empty.putUint16(0)
141+ _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(empty.b))
142+ require.Error(t, err)
143+
144+ // An observation payload claiming more entries than the bound is rejected
145+ // at the nested decode boundary.
146+ overlong := payloadWriter{}
147+ overlong.putUint16(protocol.PickerControlMaxTargets + 1)
148+ _, err = UnmarshalPickerControlResponse(pickerObserveResponseEnvelope(overlong.b))
149+ require.Error(t, err)
150+}
A internal/protocol/wire/picker_interaction_wire.go +510 -0
@@ -0,0 +1,510 @@
1+package wire
2+
3+import (
4+ "github.com/bnema/vev/internal/protocol"
5+)
6+
7+// pickerLineFlags packs the independent line booleans into one byte so the
8+// wire layout stays compact and explicit.
9+const (
10+ pickerLineAttention uint8 = 1 << iota
11+ pickerLineStopped
12+ pickerLineDim
13+ pickerLineFocusable
14+)
15+
16+// marshalPickerLines encodes structured lines, enforcing the semantic
17+// validator first so an invalid list never reaches the wire.
18+func marshalPickerLines(w *payloadWriter, lines []protocol.PickerLine) bool {
19+ if len(lines) > protocol.PickerInteractionMaxLines {
20+ return false
21+ }
22+ w.putUint16(uint16(len(lines)))
23+ for _, line := range lines {
24+ w.putString(line.Key)
25+ w.putUint8(uint8(line.Kind))
26+ w.putString(line.Label)
27+ w.putString(line.Detail)
28+ w.putUint8(uint8(line.Status))
29+ w.putString(line.StatusDetail)
30+ var flags uint8
31+ if line.Attention {
32+ flags |= pickerLineAttention
33+ }
34+ if line.Stopped {
35+ flags |= pickerLineStopped
36+ }
37+ if line.Dim {
38+ flags |= pickerLineDim
39+ }
40+ if line.Focusable {
41+ flags |= pickerLineFocusable
42+ }
43+ w.putUint8(flags)
44+ w.putUint8(uint8(line.Actions))
45+ w.putBool(line.Ephemeral)
46+ }
47+ return true
48+}
49+
50+func unmarshalPickerLines(r *payloadReader) ([]protocol.PickerLine, error) {
51+ count, err := r.getUint16()
52+ if err != nil {
53+ return nil, err
54+ }
55+ if int(count) > protocol.PickerInteractionMaxLines {
56+ return nil, protocol.ErrInvalidNavigation
57+ }
58+ lines := make([]protocol.PickerLine, 0, int(count))
59+ for range int(count) {
60+ var line protocol.PickerLine
61+ if line.Key, err = r.getString(); err != nil {
62+ return nil, err
63+ }
64+ kind, err := r.getUint8()
65+ if err != nil {
66+ return nil, err
67+ }
68+ line.Kind = protocol.PickerLineKind(kind)
69+ if line.Label, err = r.getString(); err != nil {
70+ return nil, err
71+ }
72+ if line.Detail, err = r.getString(); err != nil {
73+ return nil, err
74+ }
75+ status, err := r.getUint8()
76+ if err != nil {
77+ return nil, err
78+ }
79+ line.Status = protocol.PickerLineStatus(status)
80+ if line.StatusDetail, err = r.getString(); err != nil {
81+ return nil, err
82+ }
83+ flags, err := r.getUint8()
84+ if err != nil {
85+ return nil, err
86+ }
87+ line.Attention = flags&pickerLineAttention != 0
88+ line.Stopped = flags&pickerLineStopped != 0
89+ line.Dim = flags&pickerLineDim != 0
90+ line.Focusable = flags&pickerLineFocusable != 0
91+ actions, err := r.getUint8()
92+ if err != nil {
93+ return nil, err
94+ }
95+ line.Actions = protocol.PickerLineActions(actions)
96+ if line.Ephemeral, err = r.getBool(); err != nil {
97+ return nil, err
98+ }
99+ lines = append(lines, line)
100+ }
101+ return lines, nil
102+}
103+
104+func marshalPickerCursor(w *payloadWriter, cursor protocol.PickerCursor) {
105+ w.putString(cursor.Key)
106+ w.putUint64(uint64(cursor.Index))
107+}
108+
109+func unmarshalPickerCursor(r *payloadReader) (protocol.PickerCursor, error) {
110+ var cursor protocol.PickerCursor
111+ var err error
112+ if cursor.Key, err = r.getString(); err != nil {
113+ return protocol.PickerCursor{}, err
114+ }
115+ index, err := r.getUint64()
116+ if err != nil {
117+ return protocol.PickerCursor{}, err
118+ }
119+ cursor.Index = int(index)
120+ return cursor, nil
121+}
122+
123+// MarshalPickerOffer encodes one interaction offer.
124+func MarshalPickerOffer(offer protocol.PickerOffer) []byte {
125+ if protocol.ValidatePickerOffer(offer) != nil {
126+ return nil
127+ }
128+ w := payloadWriter{}
129+ w.putUint64(offer.InteractionID)
130+ w.putUint64(offer.RequestID)
131+ w.putUint8(uint8(offer.Intent))
132+ w.putString(offer.MoveSourceKey)
133+ w.putUint64(offer.BarrierEpoch)
134+ w.putUint64(offer.BarrierState)
135+ w.putUint64(offer.SizeEpoch)
136+ w.putString(offer.Title)
137+ return w.b
138+}
139+
140+// UnmarshalPickerOffer decodes a strict interaction offer.
141+func UnmarshalPickerOffer(data []byte) (protocol.PickerOffer, error) {
142+ r := payloadReader{b: data}
143+ var offer protocol.PickerOffer
144+ var err error
145+ if offer.InteractionID, err = r.getUint64(); err != nil {
146+ return protocol.PickerOffer{}, err
147+ }
148+ if offer.RequestID, err = r.getUint64(); err != nil {
149+ return protocol.PickerOffer{}, err
150+ }
151+ intent, err := r.getUint8()
152+ if err != nil {
153+ return protocol.PickerOffer{}, err
154+ }
155+ offer.Intent = protocol.PickerIntent(intent)
156+ if offer.MoveSourceKey, err = r.getString(); err != nil {
157+ return protocol.PickerOffer{}, err
158+ }
159+ if offer.BarrierEpoch, err = r.getUint64(); err != nil {
160+ return protocol.PickerOffer{}, err
161+ }
162+ if offer.BarrierState, err = r.getUint64(); err != nil {
163+ return protocol.PickerOffer{}, err
164+ }
165+ if offer.SizeEpoch, err = r.getUint64(); err != nil {
166+ return protocol.PickerOffer{}, err
167+ }
168+ if offer.Title, err = r.getString(); err != nil {
169+ return protocol.PickerOffer{}, err
170+ }
171+ if err := r.done(); err != nil {
172+ return protocol.PickerOffer{}, err
173+ }
174+ if err := protocol.ValidatePickerOffer(offer); err != nil {
175+ return protocol.PickerOffer{}, err
176+ }
177+ return offer, nil
178+}
179+
180+// MarshalPickerBegin encodes one interaction request.
181+func MarshalPickerBegin(begin protocol.PickerBegin) []byte {
182+ if protocol.ValidatePickerBegin(begin) != nil {
183+ return nil
184+ }
185+ w := payloadWriter{}
186+ w.putUint64(begin.RequestID)
187+ w.putUint8(uint8(begin.Intent))
188+ return w.b
189+}
190+
191+// UnmarshalPickerBegin decodes a strict interaction request.
192+func UnmarshalPickerBegin(data []byte) (protocol.PickerBegin, error) {
193+ r := payloadReader{b: data}
194+ var begin protocol.PickerBegin
195+ var err error
196+ if begin.RequestID, err = r.getUint64(); err != nil {
197+ return protocol.PickerBegin{}, err
198+ }
199+ intent, err := r.getUint8()
200+ if err != nil {
201+ return protocol.PickerBegin{}, err
202+ }
203+ begin.Intent = protocol.PickerIntent(intent)
204+ if err := r.done(); err != nil {
205+ return protocol.PickerBegin{}, err
206+ }
207+ if err := protocol.ValidatePickerBegin(begin); err != nil {
208+ return protocol.PickerBegin{}, err
209+ }
210+ return begin, nil
211+}
212+
213+// MarshalPickerSnapshot encodes one source publication. Line order is
214+// preserved verbatim.
215+func MarshalPickerSnapshot(snapshot protocol.PickerSnapshot) []byte {
216+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
217+ if protocol.ValidatePickerSnapshot(snapshot) != nil {
218+ return nil
219+ }
220+ w := payloadWriter{}
221+ w.putUint64(snapshot.InteractionID)
222+ w.putString(snapshot.SourceID)
223+ w.putUint64(snapshot.SourceRevision)
224+ w.putUint8(uint8(snapshot.Status))
225+ w.putString(snapshot.StatusDetail)
226+ if !marshalPickerLines(&w, snapshot.Recent.Lines) {
227+ return nil
228+ }
229+ marshalPickerCursor(&w, snapshot.Recent.Cursor)
230+ if !marshalPickerLines(&w, snapshot.Grouped.Lines) {
231+ return nil
232+ }
233+ marshalPickerCursor(&w, snapshot.Grouped.Cursor)
234+ if len(w.b) > protocol.PickerInteractionMaxEncodedBytes {
235+ return nil
236+ }
237+ return w.b
238+}
239+
240+// UnmarshalPickerSnapshot decodes a strict source publication.
241+func UnmarshalPickerSnapshot(data []byte) (protocol.PickerSnapshot, error) {
242+ if len(data) > protocol.PickerInteractionMaxEncodedBytes {
243+ return protocol.PickerSnapshot{}, protocol.ErrInvalidNavigation
244+ }
245+ r := payloadReader{b: data}
246+ var snapshot protocol.PickerSnapshot
247+ var err error
248+ if snapshot.InteractionID, err = r.getUint64(); err != nil {
249+ return protocol.PickerSnapshot{}, err
250+ }
251+ if snapshot.SourceID, err = r.getString(); err != nil {
252+ return protocol.PickerSnapshot{}, err
253+ }
254+ if snapshot.SourceRevision, err = r.getUint64(); err != nil {
255+ return protocol.PickerSnapshot{}, err
256+ }
257+ status, err := r.getUint8()
258+ if err != nil {
259+ return protocol.PickerSnapshot{}, err
260+ }
261+ snapshot.Status = protocol.PickerSourceStatus(status)
262+ if snapshot.StatusDetail, err = r.getString(); err != nil {
263+ return protocol.PickerSnapshot{}, err
264+ }
265+ if snapshot.Recent.Lines, err = unmarshalPickerLines(&r); err != nil {
266+ return protocol.PickerSnapshot{}, err
267+ }
268+ if snapshot.Recent.Cursor, err = unmarshalPickerCursor(&r); err != nil {
269+ return protocol.PickerSnapshot{}, err
270+ }
271+ if snapshot.Grouped.Lines, err = unmarshalPickerLines(&r); err != nil {
272+ return protocol.PickerSnapshot{}, err
273+ }
274+ if snapshot.Grouped.Cursor, err = unmarshalPickerCursor(&r); err != nil {
275+ return protocol.PickerSnapshot{}, err
276+ }
277+ if err := r.done(); err != nil {
278+ return protocol.PickerSnapshot{}, err
279+ }
280+ if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
281+ return protocol.PickerSnapshot{}, err
282+ }
283+ // Keep construction aliases populated for internal callers while both
284+ // authoritative projections remain available to client-owned sorting.
285+ snapshot.Lines, snapshot.Cursor = snapshot.Recent.Lines, snapshot.Recent.Cursor
286+ return snapshot, nil
287+}
288+
289+// MarshalPickerClose encodes a client interaction close.
290+func MarshalPickerClose(close protocol.PickerClose) []byte {
291+ if protocol.ValidatePickerClose(close) != nil {
292+ return nil
293+ }
294+ w := payloadWriter{}
295+ w.putUint64(close.InteractionID)
296+ w.putUint64(close.RequestID)
297+ return w.b
298+}
299+
300+// UnmarshalPickerClose decodes a strict client interaction close.
301+func UnmarshalPickerClose(data []byte) (protocol.PickerClose, error) {
302+ r := payloadReader{b: data}
303+ var close protocol.PickerClose
304+ var err error
305+ if close.InteractionID, err = r.getUint64(); err != nil {
306+ return protocol.PickerClose{}, err
307+ }
308+ if close.RequestID, err = r.getUint64(); err != nil {
309+ return protocol.PickerClose{}, err
310+ }
311+ if err := r.done(); err != nil {
312+ return protocol.PickerClose{}, err
313+ }
314+ if err := protocol.ValidatePickerClose(close); err != nil {
315+ return protocol.PickerClose{}, err
316+ }
317+ return close, nil
318+}
319+
320+// MarshalPickerClosed encodes the serving daemon's close confirmation.
321+func MarshalPickerClosed(closed protocol.PickerClosed) []byte {
322+ if protocol.ValidatePickerClosed(closed) != nil {
323+ return nil
324+ }
325+ w := payloadWriter{}
326+ w.putUint64(closed.InteractionID)
327+ w.putUint64(closed.BarrierEpoch)
328+ w.putUint64(closed.BarrierState)
329+ return w.b
330+}
331+
332+// UnmarshalPickerClosed decodes a strict close confirmation.
333+func UnmarshalPickerClosed(data []byte) (protocol.PickerClosed, error) {
334+ r := payloadReader{b: data}
335+ var closed protocol.PickerClosed
336+ var err error
337+ if closed.InteractionID, err = r.getUint64(); err != nil {
338+ return protocol.PickerClosed{}, err
339+ }
340+ if closed.BarrierEpoch, err = r.getUint64(); err != nil {
341+ return protocol.PickerClosed{}, err
342+ }
343+ if closed.BarrierState, err = r.getUint64(); err != nil {
344+ return protocol.PickerClosed{}, err
345+ }
346+ if err := r.done(); err != nil {
347+ return protocol.PickerClosed{}, err
348+ }
349+ if err := protocol.ValidatePickerClosed(closed); err != nil {
350+ return protocol.PickerClosed{}, err
351+ }
352+ return closed, nil
353+}
354+
355+// MarshalPickerSelection encodes an opaque row-key commit request.
356+func MarshalPickerSelection(selection protocol.PickerSelection) []byte {
357+ if protocol.ValidatePickerSelection(selection) != nil {
358+ return nil
359+ }
360+ w := payloadWriter{}
361+ w.putUint64(selection.CauseActionID)
362+ w.putUint64(selection.RequestID)
363+ w.putUint64(selection.InteractionID)
364+ w.putString(selection.SourceID)
365+ w.putUint64(selection.SourceRevision)
366+ w.putString(selection.Key)
367+ w.putUint8(uint8(selection.Action))
368+ return w.b
369+}
370+
371+// UnmarshalPickerSelection decodes a strict commit request.
372+func UnmarshalPickerSelection(data []byte) (protocol.PickerSelection, error) {
373+ r := payloadReader{b: data}
374+ var selection protocol.PickerSelection
375+ var err error
376+ if selection.CauseActionID, err = r.getUint64(); err != nil {
377+ return protocol.PickerSelection{}, err
378+ }
379+ if selection.RequestID, err = r.getUint64(); err != nil {
380+ return protocol.PickerSelection{}, err
381+ }
382+ if selection.InteractionID, err = r.getUint64(); err != nil {
383+ return protocol.PickerSelection{}, err
384+ }
385+ if selection.SourceID, err = r.getString(); err != nil {
386+ return protocol.PickerSelection{}, err
387+ }
388+ if selection.SourceRevision, err = r.getUint64(); err != nil {
389+ return protocol.PickerSelection{}, err
390+ }
391+ if selection.Key, err = r.getString(); err != nil {
392+ return protocol.PickerSelection{}, err
393+ }
394+ action, err := r.getUint8()
395+ if err != nil {
396+ return protocol.PickerSelection{}, err
397+ }
398+ selection.Action = protocol.PickerAction(action)
399+ if err := r.done(); err != nil {
400+ return protocol.PickerSelection{}, err
401+ }
402+ if err := protocol.ValidatePickerSelection(selection); err != nil {
403+ return protocol.PickerSelection{}, err
404+ }
405+ return selection, nil
406+}
407+
408+// MarshalPickerResult encodes a completed mutation report.
409+func MarshalPickerResult(result protocol.PickerResult) []byte {
410+ if protocol.ValidatePickerResult(result) != nil {
411+ return nil
412+ }
413+ w := payloadWriter{}
414+ w.putUint64(result.CauseActionID)
415+ w.putUint64(result.RequestID)
416+ w.putUint64(result.InteractionID)
417+ w.putString(result.SourceID)
418+ w.putString(result.Key)
419+ w.putUint8(uint8(result.Action))
420+ return w.b
421+}
422+
423+// UnmarshalPickerResult decodes a strict mutation report.
424+func UnmarshalPickerResult(data []byte) (protocol.PickerResult, error) {
425+ r := payloadReader{b: data}
426+ var result protocol.PickerResult
427+ var err error
428+ if result.CauseActionID, err = r.getUint64(); err != nil {
429+ return protocol.PickerResult{}, err
430+ }
431+ if result.RequestID, err = r.getUint64(); err != nil {
432+ return protocol.PickerResult{}, err
433+ }
434+ if result.InteractionID, err = r.getUint64(); err != nil {
435+ return protocol.PickerResult{}, err
436+ }
437+ if result.SourceID, err = r.getString(); err != nil {
438+ return protocol.PickerResult{}, err
439+ }
440+ if result.Key, err = r.getString(); err != nil {
441+ return protocol.PickerResult{}, err
442+ }
443+ action, err := r.getUint8()
444+ if err != nil {
445+ return protocol.PickerResult{}, err
446+ }
447+ result.Action = protocol.PickerAction(action)
448+ if err := r.done(); err != nil {
449+ return protocol.PickerResult{}, err
450+ }
451+ if err := protocol.ValidatePickerResult(result); err != nil {
452+ return protocol.PickerResult{}, err
453+ }
454+ return result, nil
455+}
456+
457+// MarshalPickerFailure encodes a bounded selection failure.
458+func MarshalPickerFailure(failure protocol.PickerFailure) []byte {
459+ if protocol.ValidatePickerFailure(failure) != nil {
460+ return nil
461+ }
462+ w := payloadWriter{}
463+ w.putUint64(failure.CauseActionID)
464+ w.putUint64(failure.RequestID)
465+ w.putUint64(failure.InteractionID)
466+ w.putString(failure.SourceID)
467+ w.putString(failure.Key)
468+ w.putUint8(uint8(failure.Action))
469+ w.putUint8(uint8(failure.Code))
470+ return w.b
471+}
472+
473+// UnmarshalPickerFailure decodes a strict selection failure.
474+func UnmarshalPickerFailure(data []byte) (protocol.PickerFailure, error) {
475+ r := payloadReader{b: data}
476+ var failure protocol.PickerFailure
477+ var err error
478+ if failure.CauseActionID, err = r.getUint64(); err != nil {
479+ return protocol.PickerFailure{}, err
480+ }
481+ if failure.RequestID, err = r.getUint64(); err != nil {
482+ return protocol.PickerFailure{}, err
483+ }
484+ if failure.InteractionID, err = r.getUint64(); err != nil {
485+ return protocol.PickerFailure{}, err
486+ }
487+ if failure.SourceID, err = r.getString(); err != nil {
488+ return protocol.PickerFailure{}, err
489+ }
490+ if failure.Key, err = r.getString(); err != nil {
491+ return protocol.PickerFailure{}, err
492+ }
493+ action, err := r.getUint8()
494+ if err != nil {
495+ return protocol.PickerFailure{}, err
496+ }
497+ failure.Action = protocol.PickerAction(action)
498+ code, err := r.getUint8()
499+ if err != nil {
500+ return protocol.PickerFailure{}, err
501+ }
502+ failure.Code = protocol.PickerFailureCode(code)
503+ if err := r.done(); err != nil {
504+ return protocol.PickerFailure{}, err
505+ }
506+ if err := protocol.ValidatePickerFailure(failure); err != nil {
507+ return protocol.PickerFailure{}, err
508+ }
509+ return failure, nil
510+}
A internal/protocol/wire/picker_interaction_wire_test.go +288 -0
@@ -0,0 +1,288 @@
1+package wire
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+func pickerOfferForTest() protocol.PickerOffer {
11+ return protocol.PickerOffer{
12+ InteractionID: 7, RequestID: 3, Intent: protocol.PickerIntentMovePane,
13+ MoveSourceKey: "ab12/work#tab-1", BarrierEpoch: 3, BarrierState: 9, SizeEpoch: 1,
14+ Title: " Sessions · recent ",
15+ }
16+}
17+
18+func pickerSnapshotForTest() protocol.PickerSnapshot {
19+ return protocol.PickerSnapshot{
20+ InteractionID: 7, SourceID: "serving", SourceRevision: 2,
21+ Status: protocol.PickerSourceOK,
22+ Lines: []protocol.PickerLine{
23+ {Kind: protocol.PickerLineSection, Label: "LOCAL", Dim: true},
24+ {
25+ Key: "ab12/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "stopped",
26+ Status: protocol.PickerLineStatusStopped, Stopped: true, Focusable: true,
27+ Actions: protocol.PickerCanNavigate | protocol.PickerCanKill,
28+ },
29+ {
30+ Key: "ab12/work#tab-1", Kind: protocol.PickerLineTab, Label: "editor",
31+ Detail: " (vim)", Attention: true, StatusDetail: "stale catalog", Focusable: true,
32+ Actions: protocol.PickerCanNavigate, Ephemeral: true,
33+ },
34+ {
35+ Key: "cd34/host", Kind: protocol.PickerLineHost, Label: "example.test",
36+ Dim: true, Focusable: true, Status: protocol.PickerLineStatusDown, StatusDetail: "unreachable",
37+ },
38+ },
39+ Cursor: protocol.PickerCursor{Key: "ab12/work#tab-1", Index: 2},
40+ }
41+}
42+
43+func TestPickerOfferRoundTrip(t *testing.T) {
44+ offer := pickerOfferForTest()
45+ payload := MarshalPickerOffer(offer)
46+ if payload == nil {
47+ t.Fatal("MarshalPickerOffer returned nil")
48+ }
49+ decoded, err := UnmarshalPickerOffer(payload)
50+ if err != nil {
51+ t.Fatalf("UnmarshalPickerOffer() error = %v", err)
52+ }
53+ if decoded != offer {
54+ t.Fatalf("UnmarshalPickerOffer() = %#v, want %#v", decoded, offer)
55+ }
56+ assertAllPrefixesFail(t, payload, UnmarshalPickerOffer)
57+ assertTrailingGarbageFails(t, payload, UnmarshalPickerOffer)
58+}
59+
60+func TestPickerBeginRoundTrip(t *testing.T) {
61+ begin := protocol.PickerBegin{RequestID: 11, Intent: protocol.PickerIntentNavigation}
62+ payload := MarshalPickerBegin(begin)
63+ if payload == nil {
64+ t.Fatal("MarshalPickerBegin returned nil")
65+ }
66+ decoded, err := UnmarshalPickerBegin(payload)
67+ if err != nil || decoded != begin {
68+ t.Fatalf("UnmarshalPickerBegin() = %#v, %v; want %#v", decoded, err, begin)
69+ }
70+ assertAllPrefixesFail(t, payload, UnmarshalPickerBegin)
71+ assertTrailingGarbageFails(t, payload, UnmarshalPickerBegin)
72+}
73+
74+func TestPickerSnapshotRoundTrip(t *testing.T) {
75+ snapshot := pickerSnapshotForTest()
76+ payload := MarshalPickerSnapshot(snapshot)
77+ if payload == nil {
78+ t.Fatal("MarshalPickerSnapshot returned nil")
79+ }
80+ decoded, err := UnmarshalPickerSnapshot(payload)
81+ if err != nil {
82+ t.Fatalf("UnmarshalPickerSnapshot() error = %v", err)
83+ }
84+ if decoded.InteractionID != snapshot.InteractionID || decoded.SourceID != snapshot.SourceID ||
85+ decoded.SourceRevision != snapshot.SourceRevision || decoded.Status != snapshot.Status ||
86+ decoded.Cursor != snapshot.Cursor || len(decoded.Lines) != len(snapshot.Lines) {
87+ t.Fatalf("UnmarshalPickerSnapshot() = %#v, want %#v", decoded, snapshot)
88+ }
89+ for i, line := range decoded.Lines {
90+ if line != snapshot.Lines[i] {
91+ t.Fatalf("UnmarshalPickerSnapshot() line %d = %#v, want %#v", i, line, snapshot.Lines[i])
92+ }
93+ }
94+ assertAllPrefixesFail(t, payload, UnmarshalPickerSnapshot)
95+ assertTrailingGarbageFails(t, payload, UnmarshalPickerSnapshot)
96+}
97+
98+func TestPickerCloseSelectionResultFailureRoundTrip(t *testing.T) {
99+ close := protocol.PickerClose{InteractionID: 7, RequestID: 3}
100+ if payload := MarshalPickerClose(close); payload == nil {
101+ t.Fatal("MarshalPickerClose returned nil")
102+ } else {
103+ decoded, err := UnmarshalPickerClose(payload)
104+ if err != nil || decoded != close {
105+ t.Fatalf("UnmarshalPickerClose() = %#v, %v; want %#v", decoded, err, close)
106+ }
107+ assertAllPrefixesFail(t, payload, UnmarshalPickerClose)
108+ assertTrailingGarbageFails(t, payload, UnmarshalPickerClose)
109+ }
110+ closed := protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 3, BarrierState: 9}
111+ if payload := MarshalPickerClosed(closed); payload == nil {
112+ t.Fatal("MarshalPickerClosed returned nil")
113+ } else {
114+ decoded, err := UnmarshalPickerClosed(payload)
115+ if err != nil || decoded != closed {
116+ t.Fatalf("UnmarshalPickerClosed() = %#v, %v; want %#v", decoded, err, closed)
117+ }
118+ assertAllPrefixesFail(t, payload, UnmarshalPickerClosed)
119+ assertTrailingGarbageFails(t, payload, UnmarshalPickerClosed)
120+ }
121+ selection := protocol.PickerSelection{
122+ CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving",
123+ SourceRevision: 2, Key: "ab12/work", Action: protocol.PickerActionMove,
124+ }
125+ if payload := MarshalPickerSelection(selection); payload == nil {
126+ t.Fatal("MarshalPickerSelection returned nil")
127+ } else {
128+ decoded, err := UnmarshalPickerSelection(payload)
129+ if err != nil || decoded != selection {
130+ t.Fatalf("UnmarshalPickerSelection() = %#v, %v; want %#v", decoded, err, selection)
131+ }
132+ assertAllPrefixesFail(t, payload, UnmarshalPickerSelection)
133+ assertTrailingGarbageFails(t, payload, UnmarshalPickerSelection)
134+ }
135+ result := protocol.PickerResult{
136+ CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving",
137+ Key: "ab12/work", Action: protocol.PickerActionKill,
138+ }
139+ if payload := MarshalPickerResult(result); payload == nil {
140+ t.Fatal("MarshalPickerResult returned nil")
141+ } else {
142+ decoded, err := UnmarshalPickerResult(payload)
143+ if err != nil || decoded != result {
144+ t.Fatalf("UnmarshalPickerResult() = %#v, %v; want %#v", decoded, err, result)
145+ }
146+ assertAllPrefixesFail(t, payload, UnmarshalPickerResult)
147+ assertTrailingGarbageFails(t, payload, UnmarshalPickerResult)
148+ }
149+ failure := protocol.PickerFailure{
150+ CauseActionID: 9, RequestID: 3, InteractionID: 7, SourceID: "serving",
151+ Key: "ab12/work", Action: protocol.PickerActionNavigate, Code: protocol.PickerRetiredTarget,
152+ }
153+ if payload := MarshalPickerFailure(failure); payload == nil {
154+ t.Fatal("MarshalPickerFailure returned nil")
155+ } else {
156+ decoded, err := UnmarshalPickerFailure(payload)
157+ if err != nil || decoded != failure {
158+ t.Fatalf("UnmarshalPickerFailure() = %#v, %v; want %#v", decoded, err, failure)
159+ }
160+ assertAllPrefixesFail(t, payload, UnmarshalPickerFailure)
161+ assertTrailingGarbageFails(t, payload, UnmarshalPickerFailure)
162+ }
163+}
164+
165+func TestPickerLineFocusFlagLayout(t *testing.T) {
166+ line := protocol.PickerLine{
167+ Key: "aa/host", Kind: protocol.PickerLineHost, Label: "host",
168+ Status: protocol.PickerLineStatusDown, Focusable: true, Attention: true,
169+ }
170+ w := &payloadWriter{}
171+ if !marshalPickerLines(w, []protocol.PickerLine{line}) {
172+ t.Fatal("marshalPickerLines rejected a focusable inspection row")
173+ }
174+ // The flags byte is the third from the end of a single-line payload:
175+ // flags, actions, ephemeral. Attention is bit 0 and focus bit 3.
176+ flags := w.b[len(w.b)-3]
177+ if flags != pickerLineAttention|pickerLineFocusable {
178+ t.Fatalf("line flags = %#x, want attention|focusable", flags)
179+ }
180+ r := payloadReader{b: w.b}
181+ lines, err := unmarshalPickerLines(&r)
182+ if err != nil {
183+ t.Fatalf("unmarshalPickerLines() error = %v", err)
184+ }
185+ if len(lines) != 1 || lines[0] != line {
186+ t.Fatalf("unmarshalPickerLines() = %#v, want %#v", lines, line)
187+ }
188+}
189+
190+func TestPickerSnapshotBounds(t *testing.T) {
191+ snapshot := pickerSnapshotForTest()
192+ oversize := snapshot
193+ oversize.SourceID = strings.Repeat("x", protocol.PickerInteractionMaxSourceBytes+1)
194+ if MarshalPickerSnapshot(oversize) != nil {
195+ t.Fatal("MarshalPickerSnapshot accepted oversize source id")
196+ }
197+ oversize = snapshot
198+ oversize.Lines = append([]protocol.PickerLine(nil), snapshot.Lines...)
199+ oversize.Lines[1].Label = strings.Repeat("y", protocol.PickerInteractionMaxDisplayBytes+1)
200+ if MarshalPickerSnapshot(oversize) != nil {
201+ t.Fatal("MarshalPickerSnapshot accepted oversize line label")
202+ }
203+ duplicate := snapshot
204+ duplicate.Lines = []protocol.PickerLine{snapshot.Lines[1], snapshot.Lines[1]}
205+ if MarshalPickerSnapshot(duplicate) != nil {
206+ t.Fatal("MarshalPickerSnapshot accepted duplicate keys")
207+ }
208+ keyedSection := snapshot
209+ keyedSection.Lines = []protocol.PickerLine{{Kind: protocol.PickerLineSection, Label: "LOCAL", Key: "nope"}}
210+ if MarshalPickerSnapshot(keyedSection) != nil {
211+ t.Fatal("MarshalPickerSnapshot accepted a keyed section line")
212+ }
213+ focusableSection := snapshot
214+ focusableSection.Lines = []protocol.PickerLine{{Kind: protocol.PickerLineSection, Label: "LOCAL", Focusable: true}}
215+ if MarshalPickerSnapshot(focusableSection) != nil {
216+ t.Fatal("MarshalPickerSnapshot accepted a focusable section line")
217+ }
218+ actionWithoutFocus := snapshot
219+ actionWithoutFocus.Lines = []protocol.PickerLine{{Key: "aa/one", Kind: protocol.PickerLineSession, Label: "one", Actions: protocol.PickerCanNavigate}}
220+ if MarshalPickerSnapshot(actionWithoutFocus) != nil {
221+ t.Fatal("MarshalPickerSnapshot accepted an action row the source never made focusable")
222+ }
223+ unknownCursor := snapshot
224+ unknownCursor.Cursor = protocol.PickerCursor{Key: "ff00/ghost", Index: 0}
225+ if MarshalPickerSnapshot(unknownCursor) != nil {
226+ t.Fatal("MarshalPickerSnapshot accepted unknown cursor key")
227+ }
228+ many := snapshot
229+ many.Lines = make([]protocol.PickerLine, protocol.PickerInteractionMaxLines+1)
230+ for i := range many.Lines {
231+ many.Lines[i] = protocol.PickerLine{Key: "k" + strings.Repeat("0", 120), Kind: protocol.PickerLineSession, Label: "d"}
232+ }
233+ if MarshalPickerSnapshot(many) != nil {
234+ t.Fatal("MarshalPickerSnapshot accepted too many lines")
235+ }
236+ if MarshalPickerSelection(protocol.PickerSelection{InteractionID: 7, SourceRevision: 2, SourceID: "serving", Action: protocol.PickerActionNavigate}) != nil {
237+ t.Fatal("MarshalPickerSelection accepted empty key")
238+ }
239+ if MarshalPickerFailure(protocol.PickerFailure{InteractionID: 7, Action: protocol.PickerActionNavigate, Code: 99}) != nil {
240+ t.Fatal("MarshalPickerFailure accepted unknown code")
241+ }
242+ if MarshalPickerClose(protocol.PickerClose{}) != nil {
243+ t.Fatal("MarshalPickerClose accepted zero interaction")
244+ }
245+ if MarshalPickerOffer(protocol.PickerOffer{InteractionID: 1, Intent: protocol.PickerIntentNavigation, MoveSourceKey: "a/b"}) != nil {
246+ t.Fatal("MarshalPickerOffer accepted a move source on a navigation intent")
247+ }
248+ if MarshalPickerBegin(protocol.PickerBegin{Intent: protocol.PickerIntentNavigation}) != nil {
249+ t.Fatal("MarshalPickerBegin accepted a zero request id")
250+ }
251+ if MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b"}) != nil {
252+ t.Fatal("MarshalPickerResult accepted an unknown action")
253+ }
254+}
255+
256+func TestPickerMessageTypes(t *testing.T) {
257+ // Direction is enforced by the sessionwire adapters (see
258+ // internal/adapters/sessionwire/picker_interaction_test.go); here we pin
259+ // the frame type each payload marshals to and that a nil payload never
260+ // reaches a transport.
261+ snapshot := pickerSnapshotForTest()
262+ cases := []struct {
263+ name string
264+ want MsgType
265+ payload []byte
266+ }{
267+ {name: "offer", want: MsgPickerOffer, payload: MarshalPickerOffer(pickerOfferForTest())},
268+ {name: "snapshot", want: MsgPickerSnapshot, payload: MarshalPickerSnapshot(snapshot)},
269+ {name: "closed", want: MsgPickerClosedServer, payload: MarshalPickerClosed(protocol.PickerClosed{InteractionID: 1})},
270+ {name: "result", want: MsgPickerResult, payload: MarshalPickerResult(protocol.PickerResult{InteractionID: 1, SourceID: "serving", Key: "a/b", Action: protocol.PickerActionKill})},
271+ {name: "failure", want: MsgPickerFailure, payload: MarshalPickerFailure(protocol.PickerFailure{InteractionID: 1, Action: protocol.PickerActionKill, Code: protocol.PickerUnknownKey})},
272+ {name: "begin", want: MsgPickerBegin, payload: MarshalPickerBegin(protocol.PickerBegin{RequestID: 1, Intent: protocol.PickerIntentNavigation})},
273+ {name: "close-client", want: MsgPickerCloseClient, payload: MarshalPickerClose(protocol.PickerClose{InteractionID: 1})},
274+ {name: "selection", want: MsgPickerSelection, payload: MarshalPickerSelection(protocol.PickerSelection{InteractionID: 1, SourceID: "serving", SourceRevision: 1, Key: "a/b", Action: protocol.PickerActionNavigate})},
275+ }
276+ for _, tt := range cases {
277+ t.Run(tt.name, func(t *testing.T) {
278+ if tt.payload == nil {
279+ t.Fatal("marshal returned nil")
280+ }
281+ if tt.want != MsgPickerOffer && tt.want != MsgPickerSnapshot && tt.want != MsgPickerClosedServer &&
282+ tt.want != MsgPickerResult && tt.want != MsgPickerFailure &&
283+ tt.want != MsgPickerBegin && tt.want != MsgPickerCloseClient && tt.want != MsgPickerSelection {
284+ t.Fatalf("unexpected frame type %d", tt.want)
285+ }
286+ })
287+ }
288+}
A internal/protocol/wire/picker_preview_wire.go +174 -0
@@ -0,0 +1,174 @@
1+package wire
2+
3+import (
4+ "errors"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// This file carries the picker preview pair: the client's request for the
11+// preview of one displayed row, and the serving daemon's bounded viewport of
12+// that row. It reuses the remote preview's cell layout and style codec so both
13+// preview kinds encode identically.
14+
15+func MarshalPickerPreviewRequest(request protocol.PickerPreviewRequest) []byte {
16+ if protocol.ValidatePickerPreviewRequest(request) != nil {
17+ return nil
18+ }
19+ w := payloadWriter{}
20+ w.putUint16(request.Version)
21+ w.putUint64(request.InteractionID)
22+ w.putString(request.SourceID)
23+ w.putString(request.Key)
24+ w.putUint16(request.Width)
25+ w.putUint16(request.Height)
26+ return w.b
27+}
28+
29+func UnmarshalPickerPreviewRequest(data []byte) (protocol.PickerPreviewRequest, error) {
30+ var request protocol.PickerPreviewRequest
31+ if len(data) > protocol.PickerPreviewMaxBytes {
32+ return request, protocol.ErrInvalidPickerPreviewRequest
33+ }
34+ r := payloadReader{b: data}
35+ var err error
36+ if request.Version, err = r.getUint16(); err != nil {
37+ return request, err
38+ }
39+ if request.InteractionID, err = r.getUint64(); err != nil {
40+ return request, err
41+ }
42+ if request.SourceID, err = r.getString(); err != nil {
43+ return request, err
44+ }
45+ if request.Key, err = r.getString(); err != nil {
46+ return request, err
47+ }
48+ if request.Width, err = r.getUint16(); err != nil {
49+ return request, err
50+ }
51+ if request.Height, err = r.getUint16(); err != nil {
52+ return request, err
53+ }
54+ if err := r.done(); err != nil {
55+ return request, err
56+ }
57+ if err := protocol.ValidatePickerPreviewRequest(request); err != nil {
58+ return request, err
59+ }
60+ return request, nil
61+}
62+
63+func MarshalPickerPreview(preview protocol.PickerPreview) []byte {
64+ if protocol.ValidatePickerPreview(preview) != nil {
65+ return nil
66+ }
67+ w := payloadWriter{}
68+ w.putUint16(preview.Version)
69+ w.putUint64(preview.InteractionID)
70+ w.putString(preview.SourceID)
71+ w.putString(preview.Key)
72+ w.putUint8(uint8(preview.Status))
73+ w.putUint16(preview.Width)
74+ w.putUint16(preview.Height)
75+ w.putUint32(uint32(len(preview.Cells)))
76+ for _, cell := range preview.Cells {
77+ putPreviewCell(&w, cell)
78+ }
79+ return w.b
80+}
81+
82+func UnmarshalPickerPreview(data []byte) (protocol.PickerPreview, error) {
83+ var preview protocol.PickerPreview
84+ if len(data) > protocol.PickerPreviewMaxBytes {
85+ return preview, protocol.ErrInvalidPickerPreview
86+ }
87+ r := payloadReader{b: data}
88+ var err error
89+ if preview.Version, err = r.getUint16(); err != nil {
90+ return preview, err
91+ }
92+ if preview.InteractionID, err = r.getUint64(); err != nil {
93+ return preview, err
94+ }
95+ if preview.SourceID, err = r.getString(); err != nil {
96+ return preview, err
97+ }
98+ if preview.Key, err = r.getString(); err != nil {
99+ return preview, err
100+ }
101+ status, err := r.getUint8()
102+ if err != nil {
103+ return preview, err
104+ }
105+ preview.Status = protocol.PickerPreviewStatus(status)
106+ if preview.Width, err = r.getUint16(); err != nil {
107+ return preview, err
108+ }
109+ if preview.Height, err = r.getUint16(); err != nil {
110+ return preview, err
111+ }
112+ count, err := r.getUint32()
113+ if err != nil {
114+ return preview, err
115+ }
116+ if count > protocol.PickerPreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) {
117+ return preview, protocol.ErrInvalidPickerPreview
118+ }
119+ if count > 0 {
120+ preview.Cells = make([]renderer.Cell, 0, count)
121+ for range count {
122+ cell, cellErr := getPreviewCell(&r)
123+ if cellErr != nil {
124+ if errors.Is(cellErr, errPreviewCellFlags) {
125+ return preview, protocol.ErrInvalidPickerPreview
126+ }
127+ return preview, cellErr
128+ }
129+ preview.Cells = append(preview.Cells, cell)
130+ }
131+ }
132+ if err := r.done(); err != nil {
133+ return preview, err
134+ }
135+ if err := protocol.ValidatePickerPreview(preview); err != nil {
136+ return preview, err
137+ }
138+ return preview, nil
139+}
140+
141+// errPreviewCellFlags reports a cell whose flag byte carries a bit this
142+// protocol does not define. Each preview kind maps it to its own error.
143+var errPreviewCellFlags = errors.New("wire: unsupported preview cell flags")
144+
145+// putPreviewCell and getPreviewCell are the shared cell layout of both preview
146+// kinds: rune, continuation flag, then the bounded style block.
147+func putPreviewCell(w *payloadWriter, cell renderer.Cell) {
148+ var flags uint8
149+ if cell.Continuation {
150+ flags = 1
151+ }
152+ w.putUint32(uint32(cell.Rune))
153+ w.putUint8(flags)
154+ putPreviewStyle(w, cell.Style)
155+}
156+
157+func getPreviewCell(r *payloadReader) (renderer.Cell, error) {
158+ runeValue, err := r.getUint32()
159+ if err != nil {
160+ return renderer.Cell{}, err
161+ }
162+ flags, err := r.getUint8()
163+ if err != nil {
164+ return renderer.Cell{}, err
165+ }
166+ if flags&^uint8(1) != 0 {
167+ return renderer.Cell{}, errPreviewCellFlags
168+ }
169+ style, err := getPreviewStyle(r)
170+ if err != nil {
171+ return renderer.Cell{}, err
172+ }
173+ return renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}, nil
174+}
A internal/protocol/wire/picker_preview_wire_test.go +198 -0
@@ -0,0 +1,198 @@
1+package wire
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "reflect"
7+ "testing"
8+
9+ renderer "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+func pickerPreviewRow(runes ...rune) []renderer.Cell {
14+ cellWidth := 0
15+ for _, r := range runes {
16+ cellWidth += renderer.RuneWidth(r)
17+ }
18+ cells := make([]renderer.Cell, 0, cellWidth)
19+ for _, r := range runes {
20+ cells = append(cells, renderer.Cell{Rune: r})
21+ for range renderer.RuneWidth(r) - 1 {
22+ cells = append(cells, renderer.Cell{Continuation: true})
23+ }
24+ }
25+ return cells
26+}
27+
28+func pickerPreviewRequestFixture() protocol.PickerPreviewRequest {
29+ return protocol.PickerPreviewRequest{
30+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
31+ Width: 80, Height: 24,
32+ }
33+}
34+
35+func pickerPreviewFixture() protocol.PickerPreview {
36+ return protocol.PickerPreview{
37+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
38+ Status: protocol.PickerPreviewOK,
39+ Width: 4, Height: 2,
40+ Cells: append(pickerPreviewRow('a', '界', 'b'), pickerPreviewRow('c', 'd', 'e', 'f')...),
41+ }
42+}
43+
44+func TestPickerPreviewRequestGoldenAndRoundTrip(t *testing.T) {
45+ request := pickerPreviewRequestFixture()
46+ want := []byte{
47+ 0x00, 0x01, // schema version
48+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, // interaction ID
49+ 0x00, 0x07, 's', 'e', 'r', 'v', 'i', 'n', 'g',
50+ 0x00, 0x08, 'a', 'a', '/', 'f', 'i', 'r', 's', 't',
51+ 0x00, 0x50, 0x00, 0x18,
52+ }
53+ got := MarshalPickerPreviewRequest(request)
54+ if !bytes.Equal(got, want) {
55+ t.Fatalf("PickerPreviewRequest bytes = %x, want %x", got, want)
56+ }
57+ back, err := UnmarshalPickerPreviewRequest(got)
58+ if err != nil {
59+ t.Fatalf("UnmarshalPickerPreviewRequest() error = %v", err)
60+ }
61+ if !reflect.DeepEqual(back, request) {
62+ t.Fatalf("request = %+v, want %+v", back, request)
63+ }
64+ assertAllPrefixesFail(t, got, UnmarshalPickerPreviewRequest)
65+ assertTrailingGarbageFails(t, got, UnmarshalPickerPreviewRequest)
66+}
67+
68+func TestPickerPreviewRequestRejectsInvalid(t *testing.T) {
69+ if MarshalPickerPreviewRequest(protocol.PickerPreviewRequest{Version: protocol.PickerPreviewSchemaVersion}) != nil {
70+ t.Fatal("MarshalPickerPreviewRequest accepted an invalid request")
71+ }
72+ tests := []struct {
73+ name string
74+ mutate func(*protocol.PickerPreviewRequest)
75+ }{
76+ {name: "schema version", mutate: func(r *protocol.PickerPreviewRequest) { r.Version = 0 }},
77+ {name: "zero interaction", mutate: func(r *protocol.PickerPreviewRequest) { r.InteractionID = 0 }},
78+ {name: "zero size", mutate: func(r *protocol.PickerPreviewRequest) { r.Height = 0 }},
79+ {name: "oversized", mutate: func(r *protocol.PickerPreviewRequest) { r.Width = protocol.PickerPreviewMaxWidth + 1 }},
80+ }
81+ for _, tt := range tests {
82+ t.Run(tt.name, func(t *testing.T) {
83+ request := pickerPreviewRequestFixture()
84+ tt.mutate(&request)
85+ payload := MarshalPickerPreviewRequest(request)
86+ if payload == nil {
87+ // The writer itself refuses to emit an invalid request.
88+ return
89+ }
90+ if _, err := UnmarshalPickerPreviewRequest(payload); !errors.Is(err, protocol.ErrInvalidPickerPreviewRequest) {
91+ t.Fatalf("UnmarshalPickerPreviewRequest() error = %v, want ErrInvalidPickerPreviewRequest", err)
92+ }
93+ })
94+ }
95+}
96+
97+func TestPickerPreviewGoldenAndRoundTrip(t *testing.T) {
98+ preview := pickerPreviewFixture()
99+ got := MarshalPickerPreview(preview)
100+ if got == nil {
101+ t.Fatal("MarshalPickerPreview() = nil for a valid viewport")
102+ }
103+ back, err := UnmarshalPickerPreview(got)
104+ if err != nil {
105+ t.Fatalf("UnmarshalPickerPreview() error = %v", err)
106+ }
107+ if !reflect.DeepEqual(back, preview) {
108+ t.Fatalf("preview = %+v, want %+v", back, preview)
109+ }
110+ rows := back.FrameRows()
111+ if len(rows) != 2 || len(rows[0]) != 4 || rows[0][1].Rune != '界' || !rows[0][2].Continuation {
112+ t.Fatalf("FrameRows() = %+v, want the encoded double-width pair", rows)
113+ }
114+ assertAllPrefixesFail(t, got, UnmarshalPickerPreview)
115+ assertTrailingGarbageFails(t, got, UnmarshalPickerPreview)
116+}
117+
118+func TestPickerPreviewRoundTripsStatusOnly(t *testing.T) {
119+ preview := protocol.PickerPreview{
120+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: "serving", Key: "aa/first",
121+ Status: protocol.PickerPreviewNoSuchTarget,
122+ }
123+ got := MarshalPickerPreview(preview)
124+ if got == nil {
125+ t.Fatal("MarshalPickerPreview() = nil for a status-only preview")
126+ }
127+ back, err := UnmarshalPickerPreview(got)
128+ if err != nil {
129+ t.Fatalf("UnmarshalPickerPreview() error = %v", err)
130+ }
131+ if !reflect.DeepEqual(back, preview) {
132+ t.Fatalf("preview = %+v, want %+v", back, preview)
133+ }
134+}
135+
136+func TestPickerPreviewRejectsInvalid(t *testing.T) {
137+ valid := pickerPreviewFixture()
138+ if MarshalPickerPreview(protocol.PickerPreview{Version: protocol.PickerPreviewSchemaVersion}) != nil {
139+ t.Fatal("MarshalPickerPreview accepted a preview without a key")
140+ }
141+
142+ // A viewport with a cell count that disagrees with its dimensions must not
143+ // decode, and the reader must reject the oversized count before allocating.
144+ truncatedCells := append([]byte(nil), MarshalPickerPreview(valid)...)
145+ countOffset := len(truncatedCells) - len(valid.Cells)*previewCellWireSize - 4
146+ for i := range 4 {
147+ truncatedCells[countOffset+i] = 0
148+ }
149+ if _, err := UnmarshalPickerPreview(truncatedCells); err == nil {
150+ t.Fatal("UnmarshalPickerPreview accepted an empty cell run for a sized viewport")
151+ }
152+
153+ // An undefined cell flag bit is a picker-invalid payload, not a remote one.
154+ flagged := append([]byte(nil), MarshalPickerPreview(valid)...)
155+ flagOffset := len(flagged) - len(valid.Cells)*previewCellWireSize + 4
156+ flagged[flagOffset] = 0x80
157+ if _, err := UnmarshalPickerPreview(flagged); !errors.Is(err, protocol.ErrInvalidPickerPreview) {
158+ t.Fatalf("UnmarshalPickerPreview(flag byte) error = %v, want ErrInvalidPickerPreview", err)
159+ }
160+
161+ // An oversized cell count fails closed before any allocation, and oversized
162+ // dimensions fail closed before the cell run is read.
163+ oversizedCount := append([]byte(nil), MarshalPickerPreview(valid)...)
164+ for i := range 4 {
165+ oversizedCount[countOffset+i] = 0xff
166+ }
167+ if _, err := UnmarshalPickerPreview(oversizedCount); !errors.Is(err, protocol.ErrInvalidPickerPreview) {
168+ t.Fatalf("UnmarshalPickerPreview(oversized count) error = %v, want ErrInvalidPickerPreview", err)
169+ }
170+ oversizedDims := append([]byte(nil), MarshalPickerPreview(valid)...)
171+ const widthOffset = 2 + 8 + 2 + 7 + 2 + 8 + 1
172+ oversizedDims[widthOffset], oversizedDims[widthOffset+1] = 0xff, 0xff
173+ if _, err := UnmarshalPickerPreview(oversizedDims); !errors.Is(err, protocol.ErrInvalidPickerPreview) {
174+ t.Fatalf("UnmarshalPickerPreview(oversized dims) error = %v, want ErrInvalidPickerPreview", err)
175+ }
176+ if _, err := UnmarshalPickerPreview(make([]byte, protocol.PickerPreviewMaxBytes+1)); !errors.Is(err, protocol.ErrInvalidPickerPreview) {
177+ t.Fatalf("UnmarshalPickerPreview(oversize payload) error = %v, want ErrInvalidPickerPreview", err)
178+ }
179+}
180+
181+// TestPreviewCellCodecIsShared pins that both preview kinds encode a cell
182+// identically: one layout, one style block.
183+func TestPreviewCellCodecIsShared(t *testing.T) {
184+ cell := renderer.Cell{Rune: '界', Style: renderer.Style{Bold: true, Foreground: 3, HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}}
185+ var w payloadWriter
186+ putPreviewCell(&w, cell)
187+ r := payloadReader{b: w.b}
188+ back, err := getPreviewCell(&r)
189+ if err != nil {
190+ t.Fatalf("getPreviewCell() error = %v", err)
191+ }
192+ if back.Rune != cell.Rune || !back.Style.Bold || back.Style.ForegroundRGB != cell.Style.ForegroundRGB {
193+ t.Fatalf("cell = %+v, want %+v", back, cell)
194+ }
195+ if err := r.done(); err != nil {
196+ t.Fatalf("cell payload has trailing bytes: %v", err)
197+ }
198+}
M internal/protocol/wire/wire.go +6 -142
@@ -462,7 +462,6 @@ func MarshalHello(h protocol.Hello) []byte {
462 marshalExactTargetSection(&w, h.ExactTarget)462 marshalExactTargetSection(&w, h.ExactTarget)
463 w.putString(string(h.PreferredTabID))463 w.putString(string(h.PreferredTabID))
464 w.putUint8(uint8(h.NavigationCapabilities))464 w.putUint8(uint8(h.NavigationCapabilities))
465- w.putUint8(uint8(h.StartupOverlay))
466 w.putBool(h.Remote)465 w.putBool(h.Remote)
467 w.putBool(h.KittyDirectGraphics)466 w.putBool(h.KittyDirectGraphics)
468 return w.b467 return w.b
@@ -538,9 +537,6 @@ func preflightHello(b []byte) error {
538 if _, err := r.getUint8(); err != nil {537 if _, err := r.getUint8(); err != nil {
539 return err538 return err
540 }539 }
541- if _, err := r.getUint8(); err != nil {
542- return err
543- }
544 if _, err := r.getBool(); err != nil {540 if _, err := r.getBool(); err != nil {
545 return err541 return err
546 }542 }
@@ -645,11 +641,6 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) {
645 return protocol.Hello{}, err641 return protocol.Hello{}, err
646 }642 }
647 h.NavigationCapabilities = protocol.NavigationCapabilities(capabilities)643 h.NavigationCapabilities = protocol.NavigationCapabilities(capabilities)
648- overlay, err := r.getUint8()
649- if err != nil {
650- return protocol.Hello{}, err
651- }
652- h.StartupOverlay = protocol.StartupOverlay(overlay)
653 if h.Remote, err = r.getBool(); err != nil {644 if h.Remote, err = r.getBool(); err != nil {
654 return protocol.Hello{}, err645 return protocol.Hello{}, err
655 }646 }
@@ -1661,13 +1652,7 @@ func MarshalRemotePreview(preview protocol.RemotePreview) []byte {
1661 w.putUint16(preview.Height)1652 w.putUint16(preview.Height)
1662 w.putUint32(uint32(len(preview.Cells)))1653 w.putUint32(uint32(len(preview.Cells)))
1663 for _, cell := range preview.Cells {1654 for _, cell := range preview.Cells {
1664- var flags uint81655+ putPreviewCell(&w, cell)
1665- if cell.Continuation {
1666- flags = 1
1667- }
1668- w.putUint32(uint32(cell.Rune))
1669- w.putUint8(flags)
1670- putPreviewStyle(&w, cell.Style)
1671 }1656 }
1672 return w.b1657 return w.b
1673 }1658 }
@@ -1716,22 +1701,14 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) {
1716 if count != 0 {1701 if count != 0 {
1717 p.Cells = make([]renderer.Cell, 0, int(count))1702 p.Cells = make([]renderer.Cell, 0, int(count))
1718 for range int(count) {1703 for range int(count) {
1719- runeValue, e := r.getUint32()1704+ cell, e := getPreviewCell(&r)
1720 if e != nil {1705 if e != nil {
1706+ if errors.Is(e, errPreviewCellFlags) {
1707+ return p, protocol.ErrInvalidRemotePreview
1708+ }
1721 return p, e1709 return p, e
1722 }1710 }
1723- flags, e := r.getUint8()1711+ p.Cells = append(p.Cells, cell)
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})
1735 }1712 }
1736 }1713 }
1737 if err := r.done(); err != nil {1714 if err := r.done(); err != nil {
@@ -1742,116 +1719,3 @@ func UnmarshalRemotePreview(data []byte) (protocol.RemotePreview, error) {
1742 }1719 }
1743 return p, nil1720 return p, nil
1744 }1721 }
1745-
1746-// MarshalNavigationDirective encodes one bounded navigation directive.
1747-func MarshalNavigationDirective(directive protocol.NavigationDirective) []byte {
1748- if directive.Action != protocol.NavigationOpenHomePicker && directive.Action != protocol.NavigationBack {
1749- return nil
1750- }
1751- if directive.Action == protocol.NavigationOpenHomePicker && directive.LeaseID.IsZero() || directive.Action == protocol.NavigationBack && !directive.LeaseID.IsZero() {
1752- return nil
1753- }
1754- w := payloadWriter{}
1755- w.putUint8(uint8(directive.Action))
1756- w.putBytes(directive.LeaseID[:])
1757- w.putUint64(directive.CauseActionID)
1758- return w.b
1759-}
1760-
1761-// UnmarshalNavigationDirective decodes one strict navigation directive.
1762-func UnmarshalNavigationDirective(b []byte) (protocol.NavigationDirective, error) {
1763- r := payloadReader{b: b}
1764- value, err := r.getUint8()
1765- if err != nil {
1766- return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1767- }
1768- lease, err := r.getBytes(len(protocol.ParkedRouteLeaseID{}))
1769- if err != nil {
1770- return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1771- }
1772- causeActionID, err := r.getUint64()
1773- if err != nil {
1774- return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1775- }
1776- if err := r.done(); err != nil {
1777- return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1778- }
1779- directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value), CauseActionID: causeActionID}
1780- copy(directive.LeaseID[:], lease)
1781- if MarshalNavigationDirective(directive) == nil {
1782- return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1783- }
1784- return directive, nil
1785-}
1786-
1787-// ValidateParkedRouteRequest enforces the closed action/target shape before a
1788-// request reaches either side's route state machine.
1789-
1790-// MarshalParkedRouteRequest encodes one retained-route operation.
1791-func MarshalParkedRouteRequest(request protocol.ParkedRouteRequest) []byte {
1792- if protocol.ValidateParkedRouteRequest(request) != nil {
1793- return nil
1794- }
1795- w := payloadWriter{}
1796- w.putUint64(request.RequestID)
1797- w.putBytes(request.LeaseID[:])
1798- w.putUint8(uint8(request.Action))
1799- marshalRemoteTargetSection(&w, request.Target, protocol.EnvironmentPolicyDaemonOwned)
1800- return w.b
1801-}
1802-
1803-// UnmarshalParkedRouteRequest decodes one strict retained-route operation.
1804-func UnmarshalParkedRouteRequest(b []byte) (protocol.ParkedRouteRequest, error) {
1805- r := payloadReader{b: b}
1806- requestID, err := r.getUint64()
1807- if err != nil {
1808- return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation
1809- }
1810- lease, err := r.getBytes(len(protocol.ParkedRouteLeaseID{}))
1811- if err != nil {
1812- return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation
1813- }
1814- action, err := r.getUint8()
1815- if err != nil {
1816- return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation
1817- }
1818- target, policy, err := unmarshalRemoteTargetSection(&r)
1819- if err != nil || policy != protocol.EnvironmentPolicyDaemonOwned || r.done() != nil {
1820- return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation
1821- }
1822- request := protocol.ParkedRouteRequest{RequestID: requestID, Action: protocol.ParkedRouteAction(action), Target: target}
1823- copy(request.LeaseID[:], lease)
1824- if protocol.ValidateParkedRouteRequest(request) != nil {
1825- return protocol.ParkedRouteRequest{}, protocol.ErrInvalidNavigation
1826- }
1827- return request, nil
1828-}
1829-
1830-// MarshalParkedRouteResponse encodes one correlated retained-route outcome.
1831-func MarshalParkedRouteResponse(response protocol.ParkedRouteResponse) []byte {
1832- if response.RequestID == 0 || response.Status < protocol.ParkedRouteReady || response.Status > protocol.ParkedRouteStaleTarget {
1833- return nil
1834- }
1835- w := payloadWriter{}
1836- w.putUint64(response.RequestID)
1837- w.putUint8(uint8(response.Status))
1838- return w.b
1839-}
1840-
1841-// UnmarshalParkedRouteResponse decodes one strict retained-route outcome.
1842-func UnmarshalParkedRouteResponse(b []byte) (protocol.ParkedRouteResponse, error) {
1843- r := payloadReader{b: b}
1844- requestID, err := r.getUint64()
1845- if err != nil {
1846- return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation
1847- }
1848- status, err := r.getUint8()
1849- if err != nil || r.done() != nil {
1850- return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation
1851- }
1852- response := protocol.ParkedRouteResponse{RequestID: requestID, Status: protocol.ParkedRouteStatus(status)}
1853- if MarshalParkedRouteResponse(response) == nil {
1854- return protocol.ParkedRouteResponse{}, protocol.ErrInvalidNavigation
1855- }
1856- return response, nil
1857-}
M internal/protocol/wire/wire_final_test.go +4 -4
@@ -12,8 +12,8 @@ import (
12 )12 )
13 13
14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {14 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 43 {15+ if protocol.Version != 51 {
16- t.Fatalf("ProtocolVersion = %d, want 43", protocol.Version)16+ t.Fatalf("ProtocolVersion = %d, want 51", protocol.Version)
17 }17 }
18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})18 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
19 if len(payload) < 2 {19 if len(payload) < 2 {
@@ -27,9 +27,9 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
27 27
28 func TestFinalHelloGoldenStrict(t *testing.T) {28 func TestFinalHelloGoldenStrict(t *testing.T) {
29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}29 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 43, 2}, make([]byte, 16+8)...)30+ want := append([]byte{0, 51, 2}, make([]byte, 16+8)...)
31 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)31 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
32- want = append(want, 0, 0)32+ want = append(want, 0)
33 got := MarshalHello(msg)33 got := MarshalHello(msg)
34 if !bytes.Equal(got, want) {34 if !bytes.Equal(got, want) {
35 t.Fatalf("Hello bytes = %x, want %x", got, want)35 t.Fatalf("Hello bytes = %x, want %x", got, want)
M internal/protocol/wire/wire_test.go +8 -8
@@ -56,8 +56,8 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
56 56
57 func appendNoNavigationTail(payload []byte) []byte {57 func appendNoNavigationTail(payload []byte) []byte {
58 // exact-target absent, preferred-tab absent, navigation capabilities none,58 // exact-target absent, preferred-tab absent, navigation capabilities none,
59- // startup overlay none, local carriage.59+ // local carriage.
60- return append(payload, 0, 0, 0, 0, 0, 0, 0)60+ return append(payload, 0, 0, 0, 0, 0, 0)
61 }61 }
62 62
63 func TestHelloGoldenAndRoundTrip(t *testing.T) {63 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 }136 }
137 got := MarshalHello(msg)137 got := MarshalHello(msg)
138 want := []byte{138 want := []byte{
139- 0x00, 0x2b, 0x00, // version, intent, first client ID byte139+ 0x00, 0x33, 0x00, // version, intent, first client ID byte
140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367 }367 }
368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369 require.Equal(t, want, MarshalTheme(cleared))369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(43), protocol.Version)370+ require.Equal(t, uint16(51), protocol.Version)
371 }371 }
372 372
373 func TestResizeGoldenAndRoundTrip(t *testing.T) {373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528 name: "minimal",528 name: "minimal",
529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530 want: []byte{530 want: []byte{
531- 0x00, 0x2b,531+ 0x00, 0x33,
532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533 0x00, // attached533 0x00, // attached
534 0x00, // self534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551 JSON: true,551 JSON: true,
552 },552 },
553 want: []byte{553 want: []byte{
554- 0x00, 0x2b,554+ 0x00, 0x33,
555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556 0x00, // attached556 0x00, // attached
557 0x01, // self557 0x01, // self
@@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999 name: "attached",999 name: "attached",
1000 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},1000 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
1001 want: []byte{1001 want: []byte{
1002- 0x00, 0x2b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,1002+ 0x00, 0x33, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
1003 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',1003 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
1004 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',1004 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
1005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -1009,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
1009 name: "control",1009 name: "control",
1010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},1010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
1011 want: []byte{1011 want: []byte{
1012- 0x00, 0x2b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1012+ 0x00, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1013 0x00, 0x02, 'l', 's', 0x00, 0x00,1013 0x00, 0x02, 'l', 's', 0x00, 0x00,
1014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1015 },1015 },
M internal/usecase/client/client.go +906 -430
@@ -136,11 +136,6 @@ const sendQueueDepth = 64
136 // budget, which now also covers connect and initial publication.136 // budget, which now also covers connect and initial publication.
137 const preWelcomeTimeout = protocol.HandshakeTimeout137 const preWelcomeTimeout = protocol.HandshakeTimeout
138 138
139-// remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
140-// host learning after terminal restoration. Learning is best-effort; a stalled
141-// learner must not block shell prompt return indefinitely.
142-const remoteHostLearnerShutdownTimeout = time.Second
143-
144 const kittyCapabilityProbeTimeout = 150 * time.Millisecond139 const kittyCapabilityProbeTimeout = 150 * time.Millisecond
145 140
146 var reconnectSleep = sleepReconnect141 var reconnectSleep = sleepReconnect
@@ -152,8 +147,6 @@ const (
152 )147 )
153 148
154 // Dependencies supplies the collaborators required by a Runner.149 // Dependencies supplies the collaborators required by a Runner.
155-type AttachHandoffFunc func(protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error)
156-
157 type Dependencies struct {150 type Dependencies struct {
158 Dialer ports.ClientDialer151 Dialer ports.ClientDialer
159 Terminal ports.Terminal152 Terminal ports.Terminal
@@ -165,10 +158,10 @@ type Dependencies struct {
165 Clipboard ports.ClipboardReader158 Clipboard ports.ClipboardReader
166 Logger *slog.Logger159 Logger *slog.Logger
167 RuntimeObserver ports.SerializedRuntimeObserver160 RuntimeObserver ports.SerializedRuntimeObserver
168- RemoteHostLearner ports.RemoteHostLearner161+ // HostRegistry resolves remote endpoints for handoffs and projects remote
169- // AttachHandoff keeps one Runner and one terminal/input ownership across a162+ // discovery, so one Runner owns one observation lifetime. It is nil only
170- // local daemon's structured remote handoff. It is nil for direct CLI attach.163+ // for embedders that accept no remote handoff at all.
171- AttachHandoff AttachHandoffFunc164+ HostRegistry ports.ClientHostRegistry
172 // LocalControlDialer is an optional dial-only control source for the165 // LocalControlDialer is an optional dial-only control source for the
173 // local daemon inventory. It must never start the daemon, create an166 // local daemon inventory. It must never start the daemon, create an
174 // attachment, or call the CLI: a missing socket reports local control167 // attachment, or call the CLI: a missing socket reports local control
@@ -205,7 +198,69 @@ type AttachRequest struct {
205 // environment and is supplied only by explicit composition.198 // environment and is supplied only by explicit composition.
206 Environment []string199 Environment []string
207 NavigationCapabilities protocol.NavigationCapabilities200 NavigationCapabilities protocol.NavigationCapabilities
208- StartupOverlay protocol.StartupOverlay201+}
202+
203+// resolveHandoff resolves one remote handoff target through the runner's host
204+// registry. The registry owns the endpoint binding (dialer and environment);
205+// the request is derived here from the validated target and that binding, so
206+// every handoff shares one endpoint authority and one observation lifetime.
207+func (r *Runner) resolveHandoff(ctx context.Context, target protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) {
208+ if err := validateHandoffTarget(target); err != nil {
209+ return nil, AttachRequest{}, err
210+ }
211+ binding, err := r.hostRegistry.ResolveEndpoint(ctx, target.Endpoint)
212+ if err != nil {
213+ return nil, AttachRequest{}, err
214+ }
215+ if binding.Dialer == nil {
216+ return nil, AttachRequest{}, errors.New("vev: remote endpoint resolved without a dialer")
217+ }
218+ hostLabel := domain.RemoteDisplayOrigin(target.Endpoint)
219+ if target.RemoteTarget != nil {
220+ hostLabel = target.RemoteTarget.DisplayOrigin
221+ }
222+ return binding.Dialer, AttachRequest{
223+ Intent: target.Intent,
224+ SessionName: target.Session,
225+ Remote: true,
226+ Environment: binding.Environment,
227+ Origin: protocol.RouteOriginDiscovery,
228+ OriginKey: target.Endpoint,
229+ RemoteTarget: target.RemoteTarget,
230+ HostLabel: hostLabel,
231+ EnvironmentPolicy: r.handoffEnvironmentPolicy(target),
232+ }, nil
233+}
234+
235+// handoffEnvironmentPolicy mirrors the composition root's picker rule: a route
236+// chosen from a locally launched client has no explicit remote selection, so
237+// its environment belongs to the daemon; every other target keeps the policy
238+// the serving daemon authorised.
239+func (r *Runner) handoffEnvironmentPolicy(target protocol.AttachTarget) protocol.EnvironmentPolicy {
240+ if !r.launchedRemote && target.RemoteTarget == nil && target.Intent != protocol.IntentNew {
241+ return protocol.EnvironmentPolicyDaemonOwned
242+ }
243+ return target.EnvironmentPolicy
244+}
245+
246+// validateHandoffTarget revalidates one daemon-authored handoff before the
247+// runner resolves it, so a malformed target never reaches registry policy.
248+func validateHandoffTarget(target protocol.AttachTarget) error {
249+ if err := protocol.ValidateAttachTarget(target); err != nil {
250+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
251+ }
252+ if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil {
253+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
254+ }
255+ if target.RemoteTarget != nil {
256+ if target.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
257+ return errors.New("vev: remote picker handoff must use daemon-owned environment")
258+ }
259+ if target.RemoteTarget.Endpoint != target.Endpoint || target.RemoteTarget.SessionName != target.Session {
260+ return errors.New("vev: remote picker handoff identity does not match route")
261+ }
262+ }
263+ return nil
209 }264 }
210 265
211 // attachRoute captures the dialer, request, and resume token needed to266 // attachRoute captures the dialer, request, and resume token needed to
@@ -236,21 +291,26 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach
236 }291 }
237 292
238 type Runner struct {293 type Runner struct {
239- ui *UI294+ ui *UI
240- dialer ports.ClientDialer295+ dialer ports.ClientDialer
241- term ports.Terminal296+ term ports.Terminal
242- clock ports.Clock297+ clock ports.Clock
243- clipboard ports.ClipboardReader298+ clipboard ports.ClipboardReader
244- logger *slog.Logger299+ logger *slog.Logger
245- runtimeObserver ports.SerializedRuntimeObserver300+ runtimeObserver ports.SerializedRuntimeObserver
246- remoteHostLearner ports.RemoteHostLearner301+ hostRegistry ports.ClientHostRegistry
247- attachHandoff AttachHandoffFunc302+ // launchedRemote records whether this runner was launched as a direct
248- remote bool303+ // remote attach. It decides the handoff environment policy exactly like the
304+ // composition root used to: a picker route from a locally launched client
305+ // has no explicit remote selection, so its environment is daemon-owned.
306+ launchedRemote bool
307+ remote bool
249 // localControlDialer is the dial-only inventory source. It never308 // localControlDialer is the dial-only inventory source. It never
250 // starts the daemon; the relay is its sole consumer.309 // starts the daemon; the relay is its sole consumer.
251 localControlDialer ports.ClientDialer310 localControlDialer ports.ClientDialer
252 origin protocol.RouteOrigin311 origin protocol.RouteOrigin
253 ledger *routeLedger312 ledger *routeLedger
313+ routeObservers *routeObserverRegistry
254 routeFailure *protocol.RouteNavigationFailure314 routeFailure *protocol.RouteNavigationFailure
255 creationFailure *protocol.SessionCreationFailure315 creationFailure *protocol.SessionCreationFailure
256 // inventoryFailure reports a failed inventory handoff to the restored316 // inventoryFailure reports a failed inventory handoff to the restored
@@ -279,13 +339,14 @@ func NewRunner(deps Dependencies) *Runner {
279 clipboard: deps.Clipboard,339 clipboard: deps.Clipboard,
280 logger: log,340 logger: log,
281 runtimeObserver: deps.RuntimeObserver,341 runtimeObserver: deps.RuntimeObserver,
282- remoteHostLearner: deps.RemoteHostLearner,342+ hostRegistry: deps.HostRegistry,
283- attachHandoff: deps.AttachHandoff,343+ launchedRemote: deps.Remote,
284 remote: deps.Remote,344 remote: deps.Remote,
285 localControlDialer: deps.LocalControlDialer,345 localControlDialer: deps.LocalControlDialer,
286 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),346 origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
287 probeCapabilities: !deps.DisableCapabilityProbe,347 probeCapabilities: !deps.DisableCapabilityProbe,
288 ledger: newRouteLedger(),348 ledger: newRouteLedger(),
349+ routeObservers: newRouteObserverRegistry(),
289 }350 }
290 }351 }
291 352
@@ -437,8 +498,7 @@ func validateAttachRequest(request AttachRequest) error {
437 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {498 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
438 return fmt.Errorf("vev: invalid session target: %w", err)499 return fmt.Errorf("vev: invalid session target: %w", err)
439 }500 }
440- homePickerRoute := request.RemoteTarget != nil || request.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || request.Intent == protocol.IntentNew501+ if err := protocol.ValidateNavigation(request.Intent, request.NavigationCapabilities); err != nil {
441- if err := protocol.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, homePickerRoute); err != nil {
442 return fmt.Errorf("vev: invalid navigation route: %w", err)502 return fmt.Errorf("vev: invalid navigation route: %w", err)
443 }503 }
444 if request.RemoteTarget == nil {504 if request.RemoteTarget == nil {
@@ -513,9 +573,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
513 attemptRequest := request573 attemptRequest := request
514 dialer := r.dialer574 dialer := r.dialer
515 var homeRoute *attachRoute575 var homeRoute *attachRoute
516- var returnRoute *attachRoute
517 returnResumeFallback := false576 returnResumeFallback := false
518- homeNavigationPending := false
519 returnNavigationPending := false577 returnNavigationPending := false
520 // navTransition owns creation and recent-route fallback lifecycle:578 // navTransition owns creation and recent-route fallback lifecycle:
521 // the captured prior route, selected identity, and settle-once state.579 // the captured prior route, selected identity, and settle-once state.
@@ -533,39 +591,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
533 var routeNavigationAction *protocol.RouteNavigationAction591 var routeNavigationAction *protocol.RouteNavigationAction
534 backoff := defaultReconnectBackoff.initial592 backoff := defaultReconnectBackoff.initial
535 themeState := &terminalThemeState{}593 themeState := &terminalThemeState{}
536- var rememberOnce sync.Once
537- var rememberWG sync.WaitGroup
538- var learnerStarted bool
539- rememberRemoteHost := func() {
540- if r.remoteHostLearner == nil {
541- return
542- }
543- rememberOnce.Do(func() {
544- learnerStarted = true
545- rememberWG.Go(func() {
546- if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
547- r.logger.Warn("remembering remote host failed", "err", err)
548- }
549- })
550- })
551- }
552- defer func() {
553- if !learnerStarted {
554- return
555- }
556- done := make(chan struct{})
557- go func() {
558- rememberWG.Wait()
559- close(done)
560- }()
561- timer := r.clock.NewTimer(remoteHostLearnerShutdownTimeout)
562- defer timer.Stop()
563- select {
564- case <-done:
565- case <-timer.C():
566- r.logger.Warn("remote host learner stalled past shutdown timeout")
567- }
568- }()
569 defer func() {594 defer func() {
570 if rawEntered {595 if rawEntered {
571 if rerr := restore(); rerr != nil && retErr == nil {596 if rerr := restore(); rerr != nil && retErr == nil {
@@ -594,26 +619,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
594 } else {619 } else {
595 attemptRequest.Intent = protocol.IntentAttach620 attemptRequest.Intent = protocol.IntentAttach
596 }621 }
597- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
598- attemptRequest.NavigationCapabilities &^= protocol.NavigationCapabilityBack | protocol.NavigationCapabilityHomePicker
599 resumeToken = route.resumeToken622 resumeToken = route.resumeToken
600 returnResumeFallback = route.resumeToken != 0623 returnResumeFallback = route.resumeToken != 0
601 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)624 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
602 backoff = defaultReconnectBackoff.initial625 backoff = defaultReconnectBackoff.initial
603 }626 }
604- enterHomePicker := func() {
605- dialer = homeRoute.dialer
606- attemptRequest = homeRoute.request
607- attemptRequest.Intent = protocol.IntentAttach
608- attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityBack
609- attemptRequest.StartupOverlay = protocol.StartupOverlaySessionPicker
610- attemptRequest.RemoteTarget = nil
611- attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
612- attemptRequest.Remote = homeRoute.request.Remote || r.remote
613- resumeToken = 0
614- remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
615- backoff = defaultReconnectBackoff.initial
616- }
617 var input *terminalInputPump627 var input *terminalInputPump
618 defer func() {628 defer func() {
619 if input != nil {629 if input != nil {
@@ -652,59 +662,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
652 return true662 return true
653 }663 }
654 664
655- runTransientHomePicker := func(attemptCtx context.Context) attachResult {
656- if homeRoute == nil {
657- return attachResult{err: errors.New("vev: home route unavailable")}
658- }
659- request := cloneAttachRequest(homeRoute.request)
660- request.Intent = protocol.IntentAttach
661- request.NavigationCapabilities = protocol.NavigationCapabilityBack
662- request.StartupOverlay = protocol.StartupOverlaySessionPicker
663- request.RemoteTarget = nil
664- request.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
665- request.Remote = false
666- if err := validateAttachRequest(request); err != nil {
667- return attachResult{err: err}
668- }
669- handshakeCtx, timedOut, finishHandshake := newHandshakeContext(attemptCtx, r.clock)
670- transport, err := boundedDial(handshakeCtx, homeRoute.dialer)
671- if err != nil {
672- err = handshakeContextError(attemptCtx, timedOut, err)
673- finishHandshake()
674- return attachResult{err: err}
675- }
676- connection, err := NewSessionConnection(transport, SessionTarget{Intent: request.Intent, SessionName: request.SessionName})
677- if err != nil {
678- finishHandshake()
679- _ = transport.Close()
680- return attachResult{err: err}
681- }
682- stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
683- localReconnect := &reconnectUI{term: r.term, rawEntered: &rawEntered}
684- result := (&attachAttempt{
685- runner: r, dialer: homeRoute.dialer, connection: connection,
686- handshakeCtx: handshakeCtx, handshakeTimedOut: timedOut,
687- finishHandshake: finishHandshake, stopHandshakeTransport: stopHandshakeTransport,
688- request: request, clientID: processClientID, milestones: &ms,
689- themeState: themeState, enterRaw: enterRaw, reconnect: localReconnect,
690- terminalInput: func() *terminalInputPump { return input },
691- }).run(attemptCtx)
692- stopHandshakeTransport()
693- finishHandshake()
694- if !result.transportClosed {
695- _ = connection.Close()
696- }
697- if result.target != nil {
698- result.handoff = bindAttachHandoff(*result.target, attachRoute{
699- dialer: homeRoute.dialer,
700- request: cloneAttachRequest(homeRoute.request),
701- resumeToken: homeRoute.resumeToken,
702- })
703- result.target = nil
704- }
705- return result
706- }
707-
708 for {665 for {
709 if err := validateAttachRequest(attemptRequest); err != nil {666 if err := validateAttachRequest(attemptRequest); err != nil {
710 return err667 return err
@@ -841,11 +798,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
841 reconnect: reconnect,798 reconnect: reconnect,
842 transition: transition,799 transition: transition,
843 linkEvents: linkEvents,800 linkEvents: linkEvents,
844- rememberRemoteHost: rememberRemoteHost,
845 terminalInput: func() *terminalInputPump {801 terminalInput: func() *terminalInputPump {
846 return input802 return input
847 },803 },
848- openHomePicker: runTransientHomePicker,
849 inventoryHome: homeRoute,804 inventoryHome: homeRoute,
850 inventoryDialer: r.localControlDialer,805 inventoryDialer: r.localControlDialer,
851 onInventoryCommitted: func() {806 onInventoryCommitted: func() {
@@ -906,6 +861,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
906 if result.err == nil && result.welcomed && navTransition.pendingInventory() {861 if result.err == nil && result.welcomed && navTransition.pendingInventory() {
907 navTransition.settleSuccess()862 navTransition.settleSuccess()
908 }863 }
864+ if result.err == nil && result.welcomed {
865+ r.routeObservers.register(attemptRequest, dialer)
866+ }
909 if result.routeCreateAction != nil {867 if result.routeCreateAction != nil {
910 action := *result.routeCreateAction868 action := *result.routeCreateAction
911 source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}869 source := attachRoute{dialer: dialer, request: cloneAttachRequest(attemptRequest), resumeToken: resumeToken}
@@ -931,7 +889,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
931 attemptRequest.ExactTarget = nil889 attemptRequest.ExactTarget = nil
932 attemptRequest.RemoteTarget = nil890 attemptRequest.RemoteTarget = nil
933 attemptRequest.PreferredTabID = ""891 attemptRequest.PreferredTabID = ""
934- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
935 attemptRequest.NavigationCapabilities = 0892 attemptRequest.NavigationCapabilities = 0
936 attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned893 attemptRequest.EnvironmentPolicy = protocol.EnvironmentPolicyClientOwned
937 resumeToken = 0894 resumeToken = 0
@@ -961,14 +918,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
961 } else {918 } else {
962 attemptRequest.Intent = protocol.IntentAttach919 attemptRequest.Intent = protocol.IntentAttach
963 }920 }
964- // Back is transient to the picker overlay. Re-derive the home-picker
965- // capability from the selected route instead of trusting request
966- // metadata, which may have been cleared after a daemon-side switch.
967- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
968 attemptRequest.NavigationCapabilities = 0921 attemptRequest.NavigationCapabilities = 0
969- if homeRoute != nil && selection.selected.presentation.kind == protocol.RouteKindRemote {
970- attemptRequest.NavigationCapabilities = protocol.NavigationCapabilityHomePicker
971- }
972 resumeToken = selection.selected.resumeToken922 resumeToken = selection.selected.resumeToken
973 routeNavigationSelection = &selection923 routeNavigationSelection = &selection
974 routeNavigationResumeFallback = selection.selected.resumeToken != 0924 routeNavigationResumeFallback = selection.selected.resumeToken != 0
@@ -976,41 +926,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
976 backoff = defaultReconnectBackoff.initial926 backoff = defaultReconnectBackoff.initial
977 continue927 continue
978 }928 }
979- if result.action != 0 {
980- switch result.action {
981- case protocol.NavigationOpenHomePicker:
982- // The daemon only sends this action after accepting a Hello that
983- // advertised Home support. The durable client-side prerequisite is
984- // the captured route itself; request metadata may have been rebased by
985- // committed identity or cursor updates since that Hello.
986- if homeRoute == nil {
987- return errors.New("vev: stale home navigation action")
988- }
989- returnRequest := attemptRequest
990- if result.sessionName != "" {
991- returnRequest.SessionName = result.sessionName
992- }
993- returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}
994- homeNavigationPending = true
995- enterHomePicker()
996- continue
997- case protocol.NavigationBack:
998- // As with Home, the daemon accepted Back in Hello before sending
999- // this action. The retained concrete route is the durable client
1000- // prerequisite; request metadata may have been rebased meanwhile.
1001- if returnRoute == nil {
1002- return errors.New("vev: stale return navigation action")
1003- }
1004- route := *returnRoute
1005- returnRoute = nil
1006- homeNavigationPending = false
1007- returnNavigationPending = true
1008- restoreReturnRoute(route)
1009- continue
1010- default:
1011- return errors.New("vev: unsupported navigation action")
1012- }
1013- }
1014 if result.target != nil || result.handoff != nil {929 if result.target != nil || result.handoff != nil {
1015 target := result.target930 target := result.target
1016 source := attachRoute{dialer: dialer, request: attemptRequest, resumeToken: resumeToken}931 source := attachRoute{dialer: dialer, request: attemptRequest, resumeToken: resumeToken}
@@ -1026,7 +941,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1026 }941 }
1027 routeRequest.Intent = protocol.IntentAttach942 routeRequest.Intent = protocol.IntentAttach
1028 routeRequest.NavigationCapabilities = 0943 routeRequest.NavigationCapabilities = 0
1029- routeRequest.StartupOverlay = protocol.StartupOverlayNone
1030 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}944 homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
1031 }945 }
1032 }946 }
@@ -1042,10 +956,12 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1042 nextDialer = source.dialer956 nextDialer = source.dialer
1043 nextRequest = r.ledger.samePeerHandoff(source.request, *target)957 nextRequest = r.ledger.samePeerHandoff(source.request, *target)
1044 } else {958 } else {
1045- if r.attachHandoff == nil {959+ if r.hostRegistry == nil {
960+ // A registry-less embedder keeps the historical handoff
961+ // error, so its caller can dial the endpoint itself.
1046 return &AttachTargetError{Target: *target}962 return &AttachTargetError{Target: *target}
1047 }963 }
1048- nextDialer, nextRequest, handoffErr = r.attachHandoff(*target)964+ nextDialer, nextRequest, handoffErr = r.resolveHandoff(ctx, *target)
1049 if handoffErr != nil {965 if handoffErr != nil {
1050 if target.Intent == protocol.IntentNew && target.RequestID != 0 {966 if target.Intent == protocol.IntentNew && target.RequestID != 0 {
1051 r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)}967 r.creationFailure = &protocol.SessionCreationFailure{RequestID: target.RequestID, Code: routeFailureCode(handoffErr)}
@@ -1059,15 +975,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1059 if target.ExactTarget != nil {975 if target.ExactTarget != nil {
1060 nextRequest.ExactTarget = target.ExactTarget976 nextRequest.ExactTarget = target.ExactTarget
1061 }977 }
1062- if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned || target.Intent == protocol.IntentNew) {
1063- nextRequest.NavigationCapabilities |= protocol.NavigationCapabilityHomePicker
1064- }
1065- if attemptRequest.StartupOverlay == protocol.StartupOverlaySessionPicker {
1066- returnRoute = nil
1067- returnResumeFallback = false
1068- nextRequest.StartupOverlay = protocol.StartupOverlayNone
1069- nextRequest.NavigationCapabilities &^= protocol.NavigationCapabilityBack
1070- }
1071 nextRequest = cloneAttachRequest(nextRequest)978 nextRequest = cloneAttachRequest(nextRequest)
1072 if err := validateAttachRequest(nextRequest); err != nil {979 if err := validateAttachRequest(nextRequest); err != nil {
1073 if target.Intent == protocol.IntentNew && target.RequestID != 0 {980 if target.Intent == protocol.IntentNew && target.RequestID != 0 {
@@ -1125,7 +1032,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1125 killedSelection = &selection1032 killedSelection = &selection
1126 dialer = selection.selected.dialer1033 dialer = selection.selected.dialer
1127 attemptRequest = cloneAttachRequest(selection.selected.request)1034 attemptRequest = cloneAttachRequest(selection.selected.request)
1128- attemptRequest.StartupOverlay = protocol.StartupOverlayNone
1129 attemptRequest.NavigationCapabilities = 01035 attemptRequest.NavigationCapabilities = 0
1130 resumeToken = selection.selected.resumeToken1036 resumeToken = selection.selected.resumeToken
1131 killedResumeFallback = resumeToken != 01037 killedResumeFallback = resumeToken != 0
@@ -1135,8 +1041,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1135 attemptRequest.Intent = protocol.IntentAttach1041 attemptRequest.Intent = protocol.IntentAttach
1136 }1042 }
1137 homeRoute = nil1043 homeRoute = nil
1138- returnRoute = nil
1139- homeNavigationPending = false
1140 returnNavigationPending = false1044 returnNavigationPending = false
1141 if navTransition.operation == navigationOperationRecent {1045 if navTransition.operation == navigationOperationRecent {
1142 navTransition.clear()1046 navTransition.clear()
@@ -1156,17 +1060,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1156 }1060 }
1157 return errors.Join(result.err, r.ledger.retireKilled(*killedSelection))1061 return errors.Join(result.err, r.ledger.retireKilled(*killedSelection))
1158 }1062 }
1159- if result.welcomed {
1160- homeNavigationPending = false
1161- }
1162- if homeNavigationPending && returnRoute != nil {
1163- route := *returnRoute
1164- returnRoute = nil
1165- homeNavigationPending = false
1166- returnNavigationPending = true
1167- restoreReturnRoute(route)
1168- continue
1169- }
1170 if navTransition.pendingCreation() {1063 if navTransition.pendingCreation() {
1171 r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)}1064 r.creationFailure = &protocol.SessionCreationFailure{RequestID: navTransition.requestID, Code: routeFailureCode(result.err)}
1172 route, ok := navTransition.restore()1065 route, ok := navTransition.restore()
@@ -1213,12 +1106,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
1213 returnResumeFallback = false1106 returnResumeFallback = false
1214 continue1107 continue
1215 }1108 }
1216- if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
1217- returnRoute = nil
1218- returnNavigationPending = false
1219- enterHomePicker()
1220- continue
1221- }
1222 if attemptRequest.Intent == protocol.IntentResume && !result.welcomed &&1109 if attemptRequest.Intent == protocol.IntentResume && !result.welcomed &&
1223 attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) {1110 attemptRequest.ExactTarget != nil && resumeNeedsExactAttach(result.err) {
1224 attemptRequest.Intent = protocol.IntentAttach1111 attemptRequest.Intent = protocol.IntentAttach
@@ -1397,9 +1284,7 @@ type attachAttempt struct {
1397 reconnect *reconnectUI1284 reconnect *reconnectUI
1398 transition *transitionUI1285 transition *transitionUI
1399 linkEvents <-chan ports.LinkEvent1286 linkEvents <-chan ports.LinkEvent
1400- rememberRemoteHost func()
1401 terminalInput func() *terminalInputPump1287 terminalInput func() *terminalInputPump
1402- openHomePicker func(context.Context) attachResult
1403 // inventoryHome is the committed local route that authorises the1288 // inventoryHome is the committed local route that authorises the
1404 // navigation-inventory relay and its handoffs. Nil disables the relay:1289 // navigation-inventory relay and its handoffs. Nil disables the relay:
1405 // local-only and ledger-less attachments never dial control sources.1290 // local-only and ledger-less attachments never dial control sources.
@@ -1425,7 +1310,6 @@ type attachResult struct {
1425 transportClosed bool1310 transportClosed bool
1426 target *protocol.AttachTarget1311 target *protocol.AttachTarget
1427 handoff *attachHandoff1312 handoff *attachHandoff
1428- action protocol.NavigationAction
1429 routeAction *protocol.RouteNavigationAction1313 routeAction *protocol.RouteNavigationAction
1430 routeCreateAction *protocol.RouteCreateSessionAction1314 routeCreateAction *protocol.RouteCreateSessionAction
1431 err error1315 err error
@@ -1473,9 +1357,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1473 transport = a.connection.Connection()1357 transport = a.connection.Connection()
1474 }1358 }
1475 request := a.request1359 request := a.request
1476- // Both parked UDP and close-and-dial home pickers only borrow a local
1477- // attachment for rendering. They do not own the client's active route.
1478- transientPicker := request.StartupOverlay == protocol.StartupOverlaySessionPicker
1479 term := a.runner.term1360 term := a.runner.term
1480 clk := a.runner.clock1361 clk := a.runner.clock
1481 intent := request.Intent1362 intent := request.Intent
@@ -1496,10 +1377,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1496 // resolve channel. Advertising the capability in Hello is what lets1377 // resolve channel. Advertising the capability in Hello is what lets
1497 // the serving palette send demands at all.1378 // the serving palette send demands at all.
1498 var inventory *inventoryRelay1379 var inventory *inventoryRelay
1499- if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote && !home.request.Remote, a.inventoryDialer) {1380+ var hybridPicker *pickerControl
1381+ if home := a.inventoryHome; home != nil && inventoryRelayEnabled(true, remote, a.inventoryDialer) {
1500 inventory = newInventoryRelay(clk, a.inventoryDialer)1382 inventory = newInventoryRelay(clk, a.inventoryDialer)
1383+ hybridPicker = &pickerControl{dialer: a.inventoryDialer}
1501 request.NavigationCapabilities |= protocol.NavigationCapabilityInventory1384 request.NavigationCapabilities |= protocol.NavigationCapabilityInventory
1502 }1385 }
1386+ // The client-picker loop owns navigate-intent presentation while the
1503 clipboard := a.runner.clipboard1387 clipboard := a.runner.clipboard
1504 log := a.runner.logger1388 log := a.runner.logger
1505 observer := a.runner.runtimeObserver1389 observer := a.runner.runtimeObserver
@@ -1589,7 +1473,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1589 PreferredTabID: request.PreferredTabID,1473 PreferredTabID: request.PreferredTabID,
1590 EnvironmentPolicy: request.EnvironmentPolicy,1474 EnvironmentPolicy: request.EnvironmentPolicy,
1591 NavigationCapabilities: request.NavigationCapabilities,1475 NavigationCapabilities: request.NavigationCapabilities,
1592- StartupOverlay: request.StartupOverlay,
1593 Remote: remote,1476 Remote: remote,
1594 }1477 }
1595 if err := sendHandshake(func() error {1478 if err := sendHandshake(func() error {
@@ -1628,11 +1511,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1628 committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)1511 committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
1629 ms.welcomed = true1512 ms.welcomed = true
1630 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)1513 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
1631- if remote {
1632- if remember := a.rememberRemoteHost; remember != nil {
1633- remember()
1634- }
1635- }
1636 case protocol.ErrorMsg:1514 case protocol.ErrorMsg:
1637 return result(false, &ProtocolError{Code: message.Code, Text: message.Text})1515 return result(false, &ProtocolError{Code: message.Code, Text: message.Text})
1638 default:1516 default:
@@ -1652,7 +1530,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1652 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {1530 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1653 return welcomedResult(errRouteTargetChanged)1531 return welcomedResult(errRouteTargetChanged)
1654 }1532 }
1655- if !transientPicker {1533+ {
1656 candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)1534 candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1657 var commitErr error1535 var commitErr error
1658 if a.killedSelection != nil {1536 if a.killedSelection != nil {
@@ -1717,11 +1595,113 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1717 // completions carry their originating interaction and query identity.1595 // completions carry their originating interaction and query identity.
1718 // The relay itself is created before Hello so the capability advertises.1596 // The relay itself is created before Hello so the capability advertises.
1719 inventoryBox := newInventoryPollBox()1597 inventoryBox := newInventoryPollBox()
1598+ pickerControlBox := newPickerControlBox()
1599+ // pickerOutcomes carries consumed-op outcomes from the stdin-pump
1600+ // goroutine to this attach loop for repaint and typed sends. The
1601+ // queue lives for the attempt; the snapshot-open path below
1602+ // installs the pump-side hook alongside each open loop. Wired here
1603+ // (after ui is bound below) would split lifetime across the
1604+ // function, so the hook install stays with the loop open/close.
1605+ pickerOutcomes := newPickerOutcomeQueue(loopCtx.Done())
1720 var inventoryResolveC chan inventoryResolveOutcome1606 var inventoryResolveC chan inventoryResolveOutcome
1721 inventoryResolvePending := false1607 inventoryResolvePending := false
1722 var nextInventoryRequestID uint64 = 11608 var nextInventoryRequestID uint64 = 1
1609+ type routeObservationOutcome struct {
1610+ authority routeObserverAuthority
1611+ observations []protocol.PickerRouteObservation
1612+ err error
1613+ }
1614+ routeObservationC := make(chan routeObservationOutcome, 1)
1615+ var routeObservationTimer ports.Timer
1616+ var routeObservationTick <-chan time.Time
1617+ var routeObservationRequestID uint64 = 1
1618+ routeObservationPending := 0
1619+ if a.runner.routeObservers == nil {
1620+ a.runner.routeObservers = newRouteObserverRegistry()
1621+ }
1622+ a.runner.routeObservers.register(request, a.dialer)
1623+ currentRouteOrigin := normalizeRouteOrigin(request.Origin, request.Remote)
1624+ currentRouteObserverKey := routeObserverKey{origin: currentRouteOrigin, originKey: normalizeRouteOriginKey(request.OriginKey, currentRouteOrigin)}
1625+ if home := a.inventoryHome; home != nil {
1626+ a.runner.routeObservers.register(home.request, home.dialer)
1627+ }
1723 inventoryOpen := false1628 inventoryOpen := false
1724 var inventoryInteraction uint641629 var inventoryInteraction uint64
1630+ // Client-picker loop state. picker tracks the admitted interaction
1631+ // namespace; pickerCurrent owns the displayed model (nil when closed).
1632+ // The loop consumes terminal-pump records and renders through the
1633+ // attach loop's sole terminal writer; commits cross on controlCh.
1634+ // Interaction IDs are daemon-assigned: the client only admits the
1635+ // namespace it was handed and retires it permanently on close.
1636+ picker := &pickerInteraction{}
1637+ var pickerCurrent *pickerLoop
1638+ // pickerSort is the client-local ordering mode; the daemon never shares it
1639+ // between attachments. pickerPreviewFrame carries the newest preview the
1640+ // serving daemon published for the displayed source.
1641+ var pickerSort = defaultPickerSort()
1642+ var pickerPreviewFrame = emptyPickerPreview()
1643+ // pickerPreview owns the client half of the row preview: the row whose
1644+ // request is on the wire and the viewport the daemon published for it.
1645+ var pickerPreview pickerPreviewClient
1646+ // pickerAcquireBarrier is the daemon output boundary the offer named: the
1647+ // client displays admitted output through it before it owns the terminal.
1648+ var pickerAcquireBarrier pickerBarrier
1649+ // pickerInput publishes who owns the terminal input to the stdin pump:
1650+ // the open interaction, the release drain window, or the session. The
1651+ // pump decodes and identifies operations; this loop applies them.
1652+ pickerInput := &pickerConsumer{}
1653+ // pickerPresentation owns who writes the terminal for the current
1654+ // client-picker interaction: acquisition barrier, local ownership,
1655+ // and ordered release. The daemon keeps mutation authority.
1656+ var pickerPresentation pickerLease
1657+ pickerRenderer := newPickerRenderer()
1658+ termSize := func() domain.Size {
1659+ geometry, err := term.Geometry()
1660+ if err != nil {
1661+ return size
1662+ }
1663+ return geometry.Size
1664+ }
1665+ // The preview request is debounced: the cursor may pass several rows while
1666+ // the user scrolls, and only the row that rests is worth capturing. The
1667+ // daemon keeps publishing the requested row afterwards, so one request per
1668+ // settled cursor is enough.
1669+ var previewTimer ports.Timer
1670+ var previewTimerCancel chan struct{}
1671+ pickerPreviewC := func() <-chan time.Time {
1672+ if previewTimer == nil {
1673+ return nil
1674+ }
1675+ return previewTimer.C()
1676+ }
1677+ clearPreviewTimer := func() {
1678+ if previewTimer != nil {
1679+ previewTimer.Stop()
1680+ previewTimer = nil
1681+ }
1682+ if previewTimerCancel != nil {
1683+ close(previewTimerCancel)
1684+ previewTimerCancel = nil
1685+ }
1686+ }
1687+ previewEvents := make(chan struct{}, 1)
1688+ armPreviewTimer := func() {
1689+ clearPreviewTimer()
1690+ timer := clk.NewTimer(pickerPreviewDebounce)
1691+ cancel := make(chan struct{})
1692+ previewTimer, previewTimerCancel = timer, cancel
1693+ go func() {
1694+ select {
1695+ case <-timer.C():
1696+ select {
1697+ case previewEvents <- struct{}{}:
1698+ default:
1699+ }
1700+ case <-cancel:
1701+ case <-loopCtx.Done():
1702+ }
1703+ }()
1704+ }
1725 var inventoryTimer ports.Timer1705 var inventoryTimer ports.Timer
1726 var inventoryTickC <-chan time.Time1706 var inventoryTickC <-chan time.Time
1727 stopInventoryTimer := func() {1707 stopInventoryTimer := func() {
@@ -1751,6 +1731,63 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1751 }1731 }
1752 }1732 }
1753 defer stopInventoryPoll()1733 defer stopInventoryPoll()
1734+ defer func() {
1735+ if routeObservationTimer != nil {
1736+ routeObservationTimer.Stop()
1737+ }
1738+ }()
1739+ hasRouteObservationTargets := func() bool {
1740+ for _, authority := range a.runner.routeObservers.snapshot() {
1741+ var excluded *protocol.ExactSessionTarget
1742+ if authority.key == currentRouteObserverKey {
1743+ excluded = request.ExactTarget
1744+ }
1745+ if len(a.runner.ledger.observationTargetsExcept(authority.key.origin, authority.key.originKey, excluded)) != 0 {
1746+ return true
1747+ }
1748+ }
1749+ return false
1750+ }
1751+ armRouteObservation := func() {
1752+ if !hasRouteObservationTargets() {
1753+ routeObservationTick = nil
1754+ return
1755+ }
1756+ if routeObservationTimer != nil {
1757+ routeObservationTimer.Stop()
1758+ }
1759+ routeObservationTimer = clk.NewTimer(inventoryPollInterval)
1760+ routeObservationTick = routeObservationTimer.C()
1761+ }
1762+ startRouteObservation := func() {
1763+ if routeObservationPending != 0 {
1764+ return
1765+ }
1766+ authorities := a.runner.routeObservers.snapshot()
1767+ for _, authority := range authorities {
1768+ var excluded *protocol.ExactSessionTarget
1769+ if authority.key == currentRouteObserverKey {
1770+ excluded = request.ExactTarget
1771+ }
1772+ targets := a.runner.ledger.observationTargetsExcept(authority.key.origin, authority.key.originKey, excluded)
1773+ if len(targets) == 0 {
1774+ continue
1775+ }
1776+ requestID := routeObservationRequestID
1777+ routeObservationRequestID++
1778+ routeObservationPending++
1779+ go func(authority routeObserverAuthority, targets []protocol.ExactSessionTarget) {
1780+ observations, err := (pickerControl{dialer: authority.dialer}).observe(loopCtx, requestID, targets)
1781+ select {
1782+ case routeObservationC <- routeObservationOutcome{authority: authority, observations: observations, err: err}:
1783+ case <-loopCtx.Done():
1784+ }
1785+ }(authority, targets)
1786+ }
1787+ if routeObservationPending == 0 {
1788+ armRouteObservation()
1789+ }
1790+ }
1754 startInventoryPoll := func() {1791 startInventoryPoll := func() {
1755 if inventory == nil || !inventoryOpen {1792 if inventory == nil || !inventoryOpen {
1756 return1793 return
@@ -1908,17 +1945,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1908 transitionWaitingFull := false1945 transitionWaitingFull := false
1909 var samePeerSwitch *samePeerSwitchPending1946 var samePeerSwitch *samePeerSwitchPending
1910 var nextSamePeerRequestID uint641947 var nextSamePeerRequestID uint64
1911- type parkedRoutePending struct {
1912- requestID uint64
1913- action protocol.ParkedRouteAction
1914- leaseID protocol.ParkedRouteLeaseID
1915- fallback *attachHandoff
1916- timer ports.Timer
1917- }
1918- var parkedPending *parkedRoutePending
1919- var nextParkedRequestID uint64
1920- parkedWaitingFull := false
1921- var parkedFullTimer ports.Timer
1922 transportFailed := make(chan struct{})1948 transportFailed := make(chan struct{})
1923 if reconnect.showing {1949 if reconnect.showing {
1924 if reconnect.remote {1950 if reconnect.remote {
@@ -1960,6 +1986,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1960 stdinDone, resizeDone chan struct{}1986 stdinDone, resizeDone chan struct{}
1961 }1987 }
1962 var foreground *foregroundRuntime1988 var foreground *foregroundRuntime
1989+ resizeEvents := make(chan domain.Geometry, 1)
1963 startForeground := func() {1990 startForeground := func() {
1964 if foreground != nil {1991 if foreground != nil {
1965 return1992 return
@@ -1983,11 +2010,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1983 foregroundCancel()2010 foregroundCancel()
1984 cancel()2011 cancel()
1985 }2012 }
1986- (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, uiGeneration: generation, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()2013+ (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, uiGeneration: generation, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease, picker: pickerInput, pickerOutcomes: pickerOutcomes}).run()
1987 }()2014 }()
1988 go func() {2015 go func() {
1989 defer close(resizeDone)2016 defer close(resizeDone)
1990- runResize(foregroundCtx, term.ResizeEvents(), sendCh, sendLease, log)2017+ runResize(foregroundCtx, term.ResizeEvents(), sendCh, resizeEvents, sendLease, log)
1991 }()2018 }()
1992 }2019 }
1993 stopForeground := func() {2020 stopForeground := func() {
@@ -2008,144 +2035,190 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2008 cancelTimer(completionTimers, id)2035 cancelTimer(completionTimers, id)
2009 }2036 }
2010 }2037 }
2011- flushSender := func() error {2038+ // displayPickerFrame composes the local picker modal and publishes it
2012- done := make(chan struct{})2039+ // through the sole terminal writer, inside the UI observation
2013- select {2040+ // transaction, exactly like daemon output. A locally admitted action
2014- case barrierCh <- done:2041+ // completes only after that write and flush succeeded.
2015- case <-loopCtx.Done():2042+ displayPickerFrame := func(loop *pickerLoop, completeAction uint64) error {
2016- return context.Canceled2043+ data := pickerRenderer.render(loop, termSize(), pickerPreviewFrame)
2017- }2044+ if data == nil {
2018- select {
2019- case <-done:
2020 return nil2045 return nil
2021- case <-loopCtx.Done():
2022- return context.Canceled
2023 }2046 }
2024- }2047+ if uiOutput != nil {
2025- clearParkedFull := func() {2048+ uiContext = outputState.uiContext(uiContext, uiContext.Status)
2026- parkedWaitingFull = false2049+ uiOutput.BeginOutput(uiContext)
2027- if parkedFullTimer != nil {2050+ if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
2028- parkedFullTimer.Stop()2051+ uiOutput.EndOutput(false)
2029- parkedFullTimer = nil2052+ return fmt.Errorf("vev: publishing picker context: %w", err)
2053+ }
2030 }2054 }
2031- }2055+ data = append([]byte("\x1b[?25l"), data...)
2032- awaitParkedFull := func() {2056+ _, werr := term.Out().Write(data)
2033- clearParkedFull()2057+ if uiOutput != nil {
2034- parkedWaitingFull = true2058+ uiOutput.EndOutput(werr == nil)
2035- parkedFullTimer = clk.NewTimer(protocol.HandshakeTimeout)
2036- }
2037- parkedFullC := func() <-chan time.Time {
2038- if parkedFullTimer == nil {
2039- return nil
2040 }2059 }
2041- return parkedFullTimer.C()2060+ if werr != nil {
2042- }2061+ return fmt.Errorf("vev: writing picker frame: %w", werr)
2043- defer clearParkedFull()
2044- clearParkedPending := func() {
2045- if parkedPending != nil && parkedPending.timer != nil {
2046- parkedPending.timer.Stop()
2047 }2062 }
2048- parkedPending = nil2063+ if ferr := term.Flush(); ferr != nil {
2064+ return fmt.Errorf("vev: flushing picker frame: %w", ferr)
2065+ }
2066+ if ui != nil {
2067+ ui.published(uiGeneration)
2068+ if completeAction != 0 {
2069+ ui.completeLocal(uiGeneration, completeAction)
2070+ }
2071+ }
2072+ return nil
2049 }2073 }
2050- parkedResponseC := func() <-chan time.Time {2074+ // publishSuppressedContext applies one daemon frame the client must not
2051- if parkedPending == nil || parkedPending.timer == nil {2075+ // write while it owns presentation. The frame content is dropped, but
2076+ // the committed context boundary still advances so the daemon's UI
2077+ // fences retire against the same boundary exactly once: an opener
2078+ // action admitted before the picker opened completes here, and no
2079+ // suppression can pair a receipt with a stale boundary.
2080+ publishSuppressedContext := func(next outputApplyState) error {
2081+ if uiOutput == nil {
2052 return nil2082 return nil
2053 }2083 }
2054- return parkedPending.timer.C()2084+ uiContext = next.uiContext(uiContext, uiContext.Status)
2055- }2085+ if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
2056- defer clearParkedPending()2086+ return fmt.Errorf("vev: publishing suppressed picker context: %w", err)
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)
2062 }2087 }
2063- geometry = geometry.NormalizePixels()2088+ if ui != nil {
2064- message := protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}2089+ ui.published(uiGeneration)
2065- if err := protocol.ValidateGeometry(message.Geometry()); err != nil {2090+ }
2066- return fmt.Errorf("validating terminal size for parked route: %w", err)2091+ return nil
2092+ }
2093+ drainPickerInput := func(interaction uint64) {
2094+ // The interaction is retired but its authoritative paint is still in
2095+ // flight: the pump consumes and drops every batch until the repaint
2096+ // lands, so a picker-bound key can never reach the session. The
2097+ // queued automation boundary is retired with it.
2098+ pickerInput.setDrain(interaction, uiGeneration)
2099+ if ui == nil {
2100+ return
2067 }2101 }
2102+ ui.mu.Lock()
2103+ inputGate.retiredUI.Store(ui.nextAction)
2104+ ui.mu.Unlock()
2105+ }
2106+ // sendPickerClose retires the daemon-side interaction so it stops owning
2107+ // input and repaints authoritatively. A duplicate close is harmless: the
2108+ // daemon rejects a close for an interaction it no longer holds.
2109+ sendPickerClose := func(interaction uint64) error {
2110+ closeMsg := protocol.PickerClose{InteractionID: interaction}
2068 select {2111 select {
2069- case controlCh <- message:2112+ case controlCh <- closeMsg:
2070 return nil2113 return nil
2071 case <-loopCtx.Done():2114 case <-loopCtx.Done():
2072 return context.Canceled2115 return context.Canceled
2073 }2116 }
2074 }2117 }
2075- sendParkedRouteRequest := func(action protocol.ParkedRouteAction, leaseID protocol.ParkedRouteLeaseID, target *domain.RemoteSessionTarget) error {2118+ // schedulePickerPreview (re)arms the debounce for the row the modal
2076- if parkedPending != nil {2119+ // displays. A row already requested is never asked for twice, so a
2077- return errors.New("vev: parked-route request already pending")2120+ // background refresh of the same row keeps its published viewport.
2121+ schedulePickerPreview := func() {
2122+ if pickerCurrent == nil || !pickerPresentation.owns() {
2123+ return
2078 }2124 }
2079- nextParkedRequestID++2125+ if !pickerPreview.needsRequest(pickerPresentation.interaction, pickerCurrent.cursorKey()) {
2080- request := protocol.ParkedRouteRequest{RequestID: nextParkedRequestID, LeaseID: leaseID, Action: action, Target: target}2126+ return
2081- if protocol.ValidateParkedRouteRequest(request) != nil {
2082- return errors.New("vev: invalid parked-route request")
2083 }2127 }
2084- select {2128+ armPreviewTimer()
2085- case controlCh <- request:2129+ }
2086- parkedPending = &parkedRoutePending{2130+ acquirePicker := func() error {
2087- requestID: request.RequestID, action: action, leaseID: leaseID,2131+ loop := pickerPresentation.pending
2088- timer: clk.NewTimer(protocol.HandshakeTimeout),2132+ if loop == nil {
2089- }2133+ pickerPresentation.reset()
2090 return nil2134 return nil
2091- case <-loopCtx.Done():
2092- return context.Canceled
2093 }2135 }
2094- }2136+ pickerPresentation.pending = nil
2095- handleParkedPicker := func(leaseID protocol.ParkedRouteLeaseID) (attachResult, bool) {2137+ pickerCurrent = loop
2096- pickerCtx, cancelPicker := context.WithCancel(ctx)2138+ pickerInput.setOwned(pickerPresentation.interaction, pickerPresentation.generation)
2097- pickerDone := make(chan struct{})2139+ if err := displayPickerFrame(loop, 0); err != nil {
2098- go func() {2140+ return err
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:
2112 }2141 }
2113- if selection.handoff != nil && transition != nil {2142+ schedulePickerPreview()
2114- transitionWaitingFull = false2143+ return nil
2115- transition.start(selection.handoff.target)2144+ }
2145+ restoreTerminalModes := func() {}
2146+ finishPickerRelease := func() {
2147+ actionID, local := pickerPresentation.finishRelease()
2148+ pickerCurrent = nil
2149+ pickerPreview.resetFor()
2150+ pickerPreviewFrame = emptyPickerPreview()
2151+ clearPreviewTimer()
2152+ pickerInput.clear()
2153+ restoreTerminalModes()
2154+ if ui != nil && local && actionID != 0 {
2155+ ui.completeLocal(uiGeneration, actionID)
2156+ }
2157+ }
2158+ abortPickerLease := func(unknownOutcome bool) {
2159+ if !pickerPresentation.active() {
2160+ return
2116 }2161 }
2117- if selection.handoff != nil && request.Remote && request.OriginKey != "" && selection.handoff.target.Endpoint == request.OriginKey && selection.handoff.target.RemoteTarget != nil {2162+ actionID, local := pickerPresentation.abort()
2118- target := *selection.handoff.target.RemoteTarget2163+ pickerCurrent = nil
2119- if err := sendParkedRouteSize(); err != nil {2164+ pickerPreview.resetFor()
2120- return welcomedResult(err), true2165+ pickerPreviewFrame = emptyPickerPreview()
2121- }2166+ clearPreviewTimer()
2122- if err := sendParkedRouteRequest(protocol.ParkedRouteSwitch, leaseID, &target); err != nil {2167+ pickerInput.clear()
2123- return welcomedResult(fmt.Errorf("vev: switching parked route: %w", err)), true2168+ restoreTerminalModes()
2169+ if ui != nil {
2170+ if local && actionID != 0 {
2171+ ui.completeLocal(uiGeneration, actionID)
2172+ } else if unknownOutcome && actionID != 0 {
2173+ ui.mu.Lock()
2174+ if record, ok := ui.records[actionID]; ok && record.Status == ports.UIActionPending {
2175+ ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
2176+ }
2177+ ui.mu.Unlock()
2124 }2178 }
2125- fallback := *selection.handoff
2126- parkedPending.fallback = &fallback
2127- return attachResult{}, false
2128 }2179 }
2129- if selection.handoff != nil {2180+ }
2130- return selection, true2181+ // resolveSupersededLease ends a lease that a newer interaction replaced:
2182+ // its pending action can no longer complete through a release paint. The
2183+ // terminal stays owned until the replacing interaction displays, because
2184+ // the replaced picker frame is still on screen: input must not reach the
2185+ // session in that window.
2186+ resolveSupersededLease := func(superseded *pickerLease) {
2187+ if superseded == nil {
2188+ return
2189+ }
2190+ actionID, local := superseded.releaseAction, superseded.releaseLocal
2191+ pickerInput.setDrain(picker.interaction, uiGeneration)
2192+ if ui == nil || actionID == 0 {
2193+ return
2131 }2194 }
2132- if selection.err != nil {2195+ if local {
2133- log.Warn("transient home picker failed; resuming parked route", "err", selection.err)2196+ ui.completeLocal(superseded.generation, actionID)
2197+ return
2134 }2198 }
2135- if err := sendParkedRouteSize(); err != nil {2199+ ui.mu.Lock()
2136- return welcomedResult(err), true2200+ if record, ok := ui.records[actionID]; ok && record.Status == ports.UIActionPending {
2201+ ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
2137 }2202 }
2138- if err := sendParkedRouteRequest(protocol.ParkedRouteResume, leaseID, nil); err != nil {2203+ ui.mu.Unlock()
2139- return welcomedResult(fmt.Errorf("vev: resuming parked route: %w", err)), true2204+ }
2205+ // syncPickerGeneration aborts a lease that belongs to a retired
2206+ // attachment generation instead of releasing a frame the daemon no
2207+ // longer associates with this interaction. No PickerClose is sent: a
2208+ // generation change means a new attachment attempt on a new connection,
2209+ // and the previous connection's interaction died with it (the daemon
2210+ // starts every attachment with no client interaction open).
2211+ syncPickerGeneration := func() {
2212+ if pickerPresentation.active() && pickerPresentation.generation != uiGeneration {
2213+ abortPickerLease(true)
2140 }2214 }
2141- return attachResult{}, false
2142 }2215 }
2143 startForeground()2216 startForeground()
2144 defer stopForeground()2217 defer stopForeground()
2145 2218
2146- // 5. Output/main loop: the only goroutine that touches the terminal.
2147 recvCh := make(chan recvResult, 1)2219 recvCh := make(chan recvResult, 1)
2148 go runRecv(loopCtx, transport, recvCh, transportFailed, log)2220 go runRecv(loopCtx, transport, recvCh, transportFailed, log)
2221+ armRouteObservation()
2149 2222
2150 requestReconnectReset := func() error {2223 requestReconnectReset := func() error {
2151 if awaitingReconnectReset {2224 if awaitingReconnectReset {
@@ -2235,19 +2308,192 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2235 select {2308 select {
2236 case <-loopCtx.Done():2309 case <-loopCtx.Done():
2237 return loopCanceledResult()2310 return loopCanceledResult()
2311+ case <-resizeEvents:
2312+ if pickerCurrent != nil && pickerPresentation.owns() {
2313+ if err := displayPickerFrame(pickerCurrent, 0); err != nil {
2314+ return welcomedResult(err)
2315+ }
2316+ pickerPreview.sent = ""
2317+ schedulePickerPreview()
2318+ }
2319+ case <-routeObservationTick:
2320+ routeObservationTick = nil
2321+ startRouteObservation()
2322+ case outcome := <-routeObservationC:
2323+ routeObservationPending--
2324+ if outcome.err == nil && a.runner.routeObservers.current(outcome.authority) &&
2325+ a.runner.ledger.applyObservations(outcome.authority.key.origin, outcome.authority.key.originKey, outcome.observations) {
2326+ if err := publishRouteSnapshot(); err != nil {
2327+ return welcomedResult(err)
2328+ }
2329+ }
2330+ if routeObservationPending == 0 {
2331+ armRouteObservation()
2332+ }
2333+ case <-pickerPreviewC():
2334+ clearPreviewTimer()
2335+ key := ""
2336+ if pickerCurrent != nil {
2337+ key = pickerCurrent.cursorKey()
2338+ }
2339+ if key == "" || !pickerPresentation.owns() || !pickerPreview.needsRequest(pickerPresentation.interaction, key) {
2340+ continue
2341+ }
2342+ // The preview pane decides the viewport: ask for exactly the
2343+ // rectangle the modal will blit it into, the same geometry the
2344+ // serving daemon sizes its capture from.
2345+ request, ok := pickerPreview.requestFor(pickerPresentation.interaction, key, pickerPreviewSize(termSize()))
2346+ if !ok {
2347+ continue
2348+ }
2349+ select {
2350+ case controlCh <- request:
2351+ pickerPreview.markSent(pickerPresentation.interaction, key)
2352+ case <-loopCtx.Done():
2353+ return loopCanceledResult()
2354+ }
2355+ case <-pickerOutcomes.signal:
2356+ for _, outcome := range pickerOutcomes.take() {
2357+ if !outcome.acceptOutcome(picker.interaction, uiGeneration) || pickerCurrent == nil || !pickerPresentation.owns() {
2358+ // Retired generation, interaction, or presentation: the pump
2359+ // already consumed the batch, so nothing reaches the session.
2360+ // Complete the admitted action so it never stays pending.
2361+ if outcome.actionID != 0 && ui != nil {
2362+ ui.completeLocal(uiGeneration, outcome.actionID)
2363+ }
2364+ continue
2365+ }
2366+ op, changed := applyPickerBatch(pickerCurrent, outcome.events)
2367+ switch {
2368+ case op.close:
2369+ // The client retired the interaction: tell the daemon so it
2370+ // stops owning input and repaints authoritatively, then keep
2371+ // consuming and dropping until that paint lands.
2372+ if err := sendPickerClose(picker.interaction); err != nil {
2373+ return welcomedResult(err)
2374+ }
2375+ if pickerPresentation.beginRelease(picker.interaction, outcome.actionID, true, false, outputState) {
2376+ drainPickerInput(picker.interaction)
2377+ } else if outcome.actionID != 0 && ui != nil {
2378+ ui.completeLocal(uiGeneration, outcome.actionID)
2379+ }
2380+ case op.kill:
2381+ // x destroys the cursor row. The typed selection carries the
2382+ // revision of the model actually presented; the picker stays
2383+ // open and refreshes from the resulting revision.
2384+ selection, ok := killSelection(pickerCurrent, outcome.actionID)
2385+ if !ok {
2386+ if outcome.actionID != 0 && ui != nil {
2387+ ui.completeLocal(uiGeneration, outcome.actionID)
2388+ }
2389+ continue
2390+ }
2391+ select {
2392+ case controlCh <- selection:
2393+ case <-loopCtx.Done():
2394+ return loopCanceledResult()
2395+ }
2396+ case op.commit:
2397+ // The typed selection crosses on controlCh: it carries the
2398+ // revision of the model actually presented. It stays pending
2399+ // through the daemon close and completes with the destination
2400+ // full paint or a typed failure.
2401+ action, ok := pickerCurrent.selectedAction()
2402+ selection, committed := protocol.PickerSelection{}, false
2403+ if ok {
2404+ selection, committed = commitSelection(pickerCurrent, action, outcome.actionID)
2405+ }
2406+ if !committed {
2407+ if err := displayPickerFrame(pickerCurrent, outcome.actionID); err != nil {
2408+ return welcomedResult(err)
2409+ }
2410+ continue
2411+ }
2412+ if hybridPicker != nil && selection.SourceID == protocol.PickerHomeSourceID {
2413+ requestID := nextInventoryRequestID
2414+ nextInventoryRequestID++
2415+ go func(selection protocol.PickerSelection) {
2416+ target, err := hybridPicker.resolve(loopCtx, requestID, selection)
2417+ pickerControlBox.offer(pickerControlOutcome{selection: &selection, target: &target, err: err})
2418+ }(selection)
2419+ continue
2420+ }
2421+ select {
2422+ case controlCh <- selection:
2423+ case <-loopCtx.Done():
2424+ return loopCanceledResult()
2425+ }
2426+ case changed:
2427+ if err := displayPickerFrame(pickerCurrent, outcome.actionID); err != nil {
2428+ return welcomedResult(err)
2429+ }
2430+ schedulePickerPreview()
2431+ default:
2432+ // The batch changed nothing visible: complete its action
2433+ // without composing a frame.
2434+ if outcome.actionID != 0 && ui != nil {
2435+ ui.completeLocal(uiGeneration, outcome.actionID)
2436+ }
2437+ }
2438+ }
2238 case <-transition.tickC():2439 case <-transition.tickC():
2239 if err := transition.advance(); err != nil {2440 if err := transition.advance(); err != nil {
2240 return welcomedResult(err)2441 return welcomedResult(err)
2241 }2442 }
2242- case <-parkedResponseC():
2243- clearParkedPending()
2244- return welcomedResult(errors.New("vev: timed out waiting for parked-route response"))
2245- case <-parkedFullC():
2246- clearParkedFull()
2247- return welcomedResult(errors.New("vev: timed out waiting for parked-route full output"))
2248 case <-inventoryTickC:2443 case <-inventoryTickC:
2249 startInventoryPoll()2444 startInventoryPoll()
2250 armInventoryPoll()2445 armInventoryPoll()
2446+ case <-pickerControlBox.wake:
2447+ outcome, ok := pickerControlBox.take()
2448+ if !ok {
2449+ continue
2450+ }
2451+ if outcome.snapshot != nil && picker.open && outcome.snapshot.InteractionID == picker.interaction {
2452+ snapshot := *outcome.snapshot
2453+ if !picker.admitSnapshot(snapshot, false) {
2454+ continue
2455+ }
2456+ if pickerCurrent != nil && pickerCurrent.interaction == snapshot.InteractionID && pickerPresentation.owns() {
2457+ pickerCurrent.replaceLines(snapshot)
2458+ if err := displayPickerFrame(pickerCurrent, 0); err != nil {
2459+ return welcomedResult(err)
2460+ }
2461+ continue
2462+ }
2463+ ready, superseded := pickerPresentation.admitSnapshot(snapshot, pickerAcquireBarrier, pickerLoopFromSnapshot(snapshot, picker.intent, pickerSort), outputState, uiGeneration)
2464+ if superseded != nil {
2465+ resolveSupersededLease(superseded)
2466+ }
2467+ if ready {
2468+ if err := acquirePicker(); err != nil {
2469+ return welcomedResult(err)
2470+ }
2471+ }
2472+ continue
2473+ }
2474+ if outcome.selection != nil && outcome.err != nil {
2475+ log.Debug("hybrid picker resolve rejected", "err", outcome.err, "source", outcome.selection.SourceID)
2476+ if outcome.selection.CauseActionID != 0 && ui != nil {
2477+ ui.completeLocal(uiGeneration, outcome.selection.CauseActionID)
2478+ }
2479+ continue
2480+ }
2481+ if outcome.target != nil && outcome.selection != nil {
2482+ if err := sendPickerClose(outcome.selection.InteractionID); err != nil {
2483+ return welcomedResult(err)
2484+ }
2485+ pickerPresentation.beginRelease(outcome.selection.InteractionID, outcome.selection.CauseActionID, false, false, outputState)
2486+ handoff := welcomedResult(nil)
2487+ handoff.target = outcome.target
2488+ if outcome.selection.CauseActionID != 0 && ui != nil {
2489+ ui.follow(uiGeneration, outcome.selection.CauseActionID)
2490+ publishUIStatus(ports.UIStatusTransitioning)
2491+ }
2492+ if home := a.inventoryHome; home != nil {
2493+ handoff.handoff = bindAttachHandoff(*outcome.target, *home)
2494+ }
2495+ return handoff
2496+ }
2251 case <-inventoryBox.wake:2497 case <-inventoryBox.wake:
2252 pollOutcome, ok := inventoryBox.take()2498 pollOutcome, ok := inventoryBox.take()
2253 if !ok {2499 if !ok {
@@ -2443,10 +2689,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2443 }2689 }
2444 continue2690 continue
2445 }2691 }
2446- if parkedWaitingFull && !o.Full {2692+ syncPickerGeneration()
2447- return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))2693+ pickerEvent := pickerPresentation.observe(o, nextState)
2694+ if pickerEvent == pickerLeaseSuppress {
2695+ // The client owns presentation: apply and ACK the daemon frame
2696+ // inside the bounded window without writing it, and advance the
2697+ // published context boundary so daemon-side fences retire.
2698+ outputState = nextState
2699+ if o.New != 0 {
2700+ ackQueue.offer(o.Epoch, o.New)
2701+ outputResetRequested = false
2702+ }
2703+ if err := publishSuppressedContext(nextState); err != nil {
2704+ return welcomedResult(err)
2705+ }
2706+ continue
2448 }2707 }
2449- if ui != nil && o.Full && foreground == nil && (parkedWaitingFull || samePeerSwitch == nil) {2708+ if ui != nil && o.Full && foreground == nil && samePeerSwitch == nil {
2450 startForeground()2709 startForeground()
2451 }2710 }
2452 if uiOutput != nil {2711 if uiOutput != nil {
@@ -2502,10 +2761,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2502 transitionWaitingFull = false2761 transitionWaitingFull = false
2503 transition.stop()2762 transition.stop()
2504 }2763 }
2505- if parkedWaitingFull {
2506- clearParkedFull()
2507- startForeground()
2508- }
2509 if o.New != 0 {2764 if o.New != 0 {
2510 ackQueue.offer(o.Epoch, o.New)2765 ackQueue.offer(o.Epoch, o.New)
2511 outputResetRequested = false2766 outputResetRequested = false
@@ -2519,6 +2774,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2519 log.Debug("received first output")2774 log.Debug("received first output")
2520 endHandshake()2775 endHandshake()
2521 }2776 }
2777+ switch pickerEvent {
2778+ case pickerLeaseAcquire:
2779+ // The acquisition barrier is displayed: the client may now
2780+ // paint and own the picker frame.
2781+ if err := acquirePicker(); err != nil {
2782+ return welcomedResult(err)
2783+ }
2784+ case pickerLeaseRelease:
2785+ // The authoritative full paint was written and published:
2786+ // release the terminal, resume input, and complete the
2787+ // pending cancel locally. A commit completes through the
2788+ // handoff instead.
2789+ finishPickerRelease()
2790+ }
2522 case protocol.UIViewUpdate:2791 case protocol.UIViewUpdate:
2523 nextState, accepted, needsReset := outputState.nextView(message)2792 nextState, accepted, needsReset := outputState.nextView(message)
2524 if needsReset && !outputResetRequested {2793 if needsReset && !outputResetRequested {
@@ -2552,11 +2821,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2552 publishUIStatus(ports.UIStatusTransitioning)2821 publishUIStatus(ports.UIStatusTransitioning)
2553 }2822 }
2554 target := message2823 target := message
2555- if transientPicker {
2556- handoff := welcomedResult(nil)
2557- handoff.target = &target
2558- return handoff
2559- }
2560 if transition != nil {2824 if transition != nil {
2561 transitionWaitingFull = false2825 transitionWaitingFull = false
2562 transition.start(target)2826 transition.start(target)
@@ -2593,72 +2857,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2593 handoff := welcomedResult(nil)2857 handoff := welcomedResult(nil)
2594 handoff.target = &target2858 handoff.target = &target
2595 return handoff2859 return handoff
2596- case protocol.ParkedRouteResponse:
2597- response := message
2598- if parkedPending == nil || response.RequestID != parkedPending.requestID {
2599- return welcomedResult(errors.New("vev: unexpected parked-route response"))
2600- }
2601- pending := *parkedPending
2602- clearParkedPending()
2603- switch pending.action {
2604- case protocol.ParkedRoutePrepare:
2605- if response.Status != protocol.ParkedRouteReady {
2606- fallback := welcomedResult(nil)
2607- fallback.action = protocol.NavigationOpenHomePicker
2608- return fallback
2609- }
2610- if selection, done := handleParkedPicker(pending.leaseID); done {
2611- return selection
2612- }
2613- case protocol.ParkedRouteResume:
2614- if response.Status != protocol.ParkedRouteResumed {
2615- return welcomedResult(errors.New("vev: parked route could not resume"))
2616- }
2617- if err := publishRouteSnapshot(); err != nil {
2618- return welcomedResult(err)
2619- }
2620- awaitParkedFull()
2621- case protocol.ParkedRouteSwitch:
2622- switch response.Status {
2623- case protocol.ParkedRouteSwitched:
2624- awaitParkedFull()
2625- continue
2626- case protocol.ParkedRouteStaleTarget:
2627- if transition != nil {
2628- transition.stop()
2629- }
2630- if selection, done := handleParkedPicker(pending.leaseID); done {
2631- return selection
2632- }
2633- default:
2634- if pending.fallback == nil {
2635- return welcomedResult(errors.New("vev: parked route switch failed"))
2636- }
2637- fallback := welcomedResult(nil)
2638- fallback.handoff = pending.fallback
2639- return fallback
2640- }
2641- }
2642- case protocol.NavigationDirective:
2643- if ui != nil {
2644- ui.follow(uiGeneration, message.CauseActionID)
2645- publishUIStatus(ports.UIStatusTransitioning)
2646- }
2647- directive := message
2648- datagramRoute := transport.Capabilities().PreferredOutputWindow == 1
2649- if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
2650- stopForeground()
2651- if err := flushSender(); err != nil {
2652- return welcomedResult(fmt.Errorf("vev: parking remote foreground: %w", err))
2653- }
2654- if err := sendParkedRouteRequest(protocol.ParkedRoutePrepare, directive.LeaseID, nil); err != nil {
2655- return welcomedResult(fmt.Errorf("vev: preparing parked route: %w", err))
2656- }
2657- continue
2658- }
2659- navigation := welcomedResult(nil)
2660- navigation.action = directive.Action
2661- return navigation
2662 case protocol.NavigationInventoryDemand:2860 case protocol.NavigationInventoryDemand:
2663 demand := message2861 demand := message
2664 if inventory == nil {2862 if inventory == nil {
@@ -2718,6 +2916,164 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2718 // Overlay-owned liveness. The relay never waits on it:2916 // Overlay-owned liveness. The relay never waits on it:
2719 // publications carry the only state it tracks.2917 // publications carry the only state it tracks.
2720 continue2918 continue
2919+ case protocol.PickerOffer:
2920+ offer := message
2921+ if offer.InteractionID <= picker.retired {
2922+ continue
2923+ }
2924+ if !picker.open || picker.interaction != offer.InteractionID {
2925+ picker.setOpen(true, offer.InteractionID)
2926+ }
2927+ picker.setIntent(offer.InteractionID, offer.Intent)
2928+ pickerAcquireBarrier = pickerBarrier{epoch: offer.BarrierEpoch, state: offer.BarrierState}
2929+ syncPickerGeneration()
2930+ if hybridPicker != nil && offer.Intent == protocol.PickerIntentNavigation {
2931+ requestID := nextInventoryRequestID
2932+ nextInventoryRequestID++
2933+ go func(interaction uint64) {
2934+ snapshot, err := hybridPicker.snapshot(loopCtx, requestID, interaction)
2935+ if err != nil {
2936+ log.Warn("hybrid picker authority unavailable", "err", err)
2937+ return
2938+ }
2939+ pickerControlBox.offer(pickerControlOutcome{snapshot: &snapshot})
2940+ }(offer.InteractionID)
2941+ }
2942+ continue
2943+ case protocol.PickerSnapshot:
2944+ snapshot := message
2945+ // A hybrid client presents only the stable home authority for
2946+ // navigation. The serving daemon still owns the offer/barrier and
2947+ // close lifecycle, but its rows must never be merged or duplicated.
2948+ if hybridPicker != nil && picker.intent == protocol.PickerIntentNavigation && snapshot.SourceID == protocol.PickerServingSourceID {
2949+ continue
2950+ }
2951+ if snapshot.InteractionID <= picker.retired {
2952+ // A late snapshot from before a close never reopens the
2953+ // interaction: retiring it is permanent.
2954+ continue
2955+ }
2956+ // Snapshots are self-opening: the daemon publishes them
2957+ // only for an interaction it admitted, so the first
2958+ // snapshot for an unknown ID opens the namespace.
2959+ if !picker.open || picker.interaction != snapshot.InteractionID {
2960+ picker.setOpen(true, snapshot.InteractionID)
2961+ }
2962+ syncPickerGeneration()
2963+ if !picker.admitSnapshot(snapshot, false) {
2964+ continue
2965+ }
2966+ // A newer publication of the source already on display replaces
2967+ // its lines in place, so the local cursor and search survive a
2968+ // background refresh. A first-or-foreign source installs a new
2969+ // model through the lease below.
2970+ if pickerCurrent != nil && pickerCurrent.interaction == snapshot.InteractionID && pickerPresentation.owns() {
2971+ pickerCurrent.replaceLines(snapshot)
2972+ if err := displayPickerFrame(pickerCurrent, 0); err != nil {
2973+ return welcomedResult(err)
2974+ }
2975+ schedulePickerPreview()
2976+ continue
2977+ }
2978+ // The lease decides presentation: a barrier still ahead keeps
2979+ // displaying daemon output until the exact epoch/state the
2980+ // snapshot was built against is applied, so no older paint can
2981+ // land after the picker frame.
2982+ ready, superseded := pickerPresentation.admitSnapshot(snapshot, pickerAcquireBarrier, pickerLoopFromSnapshot(snapshot, picker.intent, pickerSort), outputState, uiGeneration)
2983+ if superseded != nil {
2984+ // A superseding interaction ended the previous one: resolve
2985+ // that lease, never the one just installed.
2986+ resolveSupersededLease(superseded)
2987+ }
2988+ if !ready {
2989+ continue
2990+ }
2991+ if err := acquirePicker(); err != nil {
2992+ return welcomedResult(err)
2993+ }
2994+ continue
2995+ case protocol.PickerClosed:
2996+ closed := message
2997+ if !pickerPresentation.beginRelease(closed.InteractionID, 0, false, true, outputState) {
2998+ // The client already retired this interaction: the daemon's own
2999+ // close is the ordering barrier its release waits for, so
3000+ // record it and re-capture the boundary the daemon named.
3001+ pickerPresentation.confirmDaemonClose(closed.InteractionID, outputState)
3002+ }
3003+ if pickerPresentation.releasingFor(closed.InteractionID) {
3004+ // The daemon retired the interaction (a resolved selection,
3005+ // a superseding overlay, or the echo of the client's own
3006+ // cancel). Keep consuming and dropping input until the
3007+ // authoritative paint lands.
3008+ drainPickerInput(closed.InteractionID)
3009+ }
3010+ if picker.interaction == closed.InteractionID || !picker.open {
3011+ picker.setOpen(false, closed.InteractionID)
3012+ }
3013+ continue
3014+ case protocol.PickerPreview:
3015+ if pickerCurrent == nil || !pickerPresentation.owns() {
3016+ continue
3017+ }
3018+ if !pickerPreview.accept(message, pickerPresentation.interaction, pickerCurrent.cursorKey()) {
3019+ // A late answer for a row the user already left, or a
3020+ // viewport that cannot be displayed: keep the current one.
3021+ continue
3022+ }
3023+ pickerPreviewFrame = pickerPreview.frame
3024+ if err := displayPickerFrame(pickerCurrent, 0); err != nil {
3025+ return welcomedResult(err)
3026+ }
3027+ continue
3028+ case protocol.PickerResult:
3029+ // A completed in-place mutation (kill or move). The daemon
3030+ // republishes the source right after, so the picker needs no
3031+ // local model change: complete the action and keep presenting.
3032+ result := message
3033+ if result.CauseActionID != 0 && ui != nil {
3034+ ui.completeLocal(uiGeneration, result.CauseActionID)
3035+ }
3036+ continue
3037+ case protocol.PickerFailure:
3038+ failure := message
3039+ // Commit rejections fail the exact ui-driver action
3040+ // that caused them via CauseActionID. Stale-revision
3041+ // and retired-target close the loop: the cursor it
3042+ // committed from no longer exists. Unknown-key and
3043+ // navigation failures keep the admitted revision so
3044+ // the user can retry.
3045+ if failure.InteractionID == picker.interaction && failure.CauseActionID != 0 && ui != nil {
3046+ ui.mu.Lock()
3047+ if record, ok := ui.records[failure.CauseActionID]; ok && record.Status == ports.UIActionPending {
3048+ ui.finishLocked(failure.CauseActionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
3049+ }
3050+ ui.mu.Unlock()
3051+ }
3052+ switch failure.Code {
3053+ case protocol.PickerStaleRevision, protocol.PickerRetiredTarget:
3054+ // The interaction can no longer be committed from. Close it
3055+ // daemon-side so it stops owning input and restores the screen
3056+ // authoritatively, then drain until that paint lands instead of
3057+ // dropping the lease and leaving the daemon interaction open.
3058+ // A second rejection for an interaction already draining must
3059+ // not restart or cancel that release.
3060+ if failure.InteractionID != picker.interaction && picker.open {
3061+ continue
3062+ }
3063+ if pickerPresentation.releasingFor(failure.InteractionID) {
3064+ continue
3065+ }
3066+ picker.setOpen(false, failure.InteractionID)
3067+ if err := sendPickerClose(failure.InteractionID); err != nil {
3068+ return welcomedResult(err)
3069+ }
3070+ if pickerPresentation.beginRelease(failure.InteractionID, 0, false, false, outputState) {
3071+ drainPickerInput(failure.InteractionID)
3072+ } else {
3073+ abortPickerLease(false)
3074+ }
3075+ }
3076+ continue
2721 case protocol.RouteCreateSessionAction:3077 case protocol.RouteCreateSessionAction:
2722 action := message3078 action := message
2723 if action.Validate() != nil {3079 if action.Validate() != nil {
@@ -2774,9 +3130,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2774 }3130 }
2775 name = identity.Target.SessionName3131 name = identity.Target.SessionName
2776 committedIdentity = cloneCommittedIdentity(&identity)3132 committedIdentity = cloneCommittedIdentity(&identity)
2777- if transientPicker {
2778- continue
2779- }
2780 if a.runner.ledger == nil {3133 if a.runner.ledger == nil {
2781 return welcomedResult(errors.New("vev: route ledger unavailable"))3134 return welcomedResult(errors.New("vev: route ledger unavailable"))
2782 }3135 }
@@ -3084,6 +3437,11 @@ type terminalReadResult struct {
3084 generation uint643437 generation uint64
3085 actionID uint643438 actionID uint64
3086 endBatch bool3439 endBatch bool
3440+ // keys/text carry the decoded ui-driver op for picker interception.
3441+ // Physical input leaves both empty; the picker consumer then decodes the
3442+ // raw bytes itself.
3443+ keys []string
3444+ text string
3087 }3445 }
3088 3446
3089 // terminalAutomationRequest is admitted only by the foreground scanner owner.3447 // terminalAutomationRequest is admitted only by the foreground scanner owner.
@@ -3456,6 +3814,12 @@ type stdinPump struct {
3456 afterInputTake func() // test synchronization hook3814 afterInputTake func() // test synchronization hook
3457 afterInputDelivered func() // test synchronization hook3815 afterInputDelivered func() // test synchronization hook
3458 sendLease *foregroundSendLease3816 sendLease *foregroundSendLease
3817+ // picker publishes who owns terminal input and decodes batches into
3818+ // identified operations. The pump never touches the picker model.
3819+ picker *pickerConsumer
3820+ // pickerOutcomes carries those operations to the attach loop, which
3821+ // applies them and flushes typed sends in controlCh order.
3822+ pickerOutcomes *pickerOutcomeQueue
3459 }3823 }
3460 3824
3461 func (p *stdinPump) run() {3825 func (p *stdinPump) run() {
@@ -3538,6 +3902,7 @@ func (p *stdinPump) run() {
3538 sink := coalescer.Scan3902 sink := coalescer.Scan
3539 if p.clipboard != nil {3903 if p.clipboard != nil {
3540 ci := &clipboardIntercept{3904 ci := &clipboardIntercept{
3905+ ctx: p.ctx,
3541 coalescer: coalescer,3906 coalescer: coalescer,
3542 reader: p.clipboard,3907 reader: p.clipboard,
3543 log: p.logger,3908 log: p.logger,
@@ -3552,6 +3917,20 @@ func (p *stdinPump) run() {
3552 }3917 }
3553 sink = ci.Scan3918 sink = ci.Scan
3554 }3919 }
3920+ // routeInput keeps the marker/palette demultiplexing intact while a
3921+ // client-owned picker owns the terminal: the scanner still resolves
3922+ // probe replies, but every byte that would have become session input is
3923+ // decoded for the picker instead. There is no fallback to the PTY
3924+ // pipeline while the picker owns input.
3925+ routeInput := func(data []byte) {
3926+ if p.picker != nil {
3927+ if outcome, consumed := p.picker.consume(terminalReadResult{data: data, source: terminalInputHuman}); consumed {
3928+ p.pickerOutcomes.offer(outcome)
3929+ return
3930+ }
3931+ }
3932+ sink(data)
3933+ }
3555 input := p.input3934 input := p.input
3556 ownedInput := input == nil3935 ownedInput := input == nil
3557 if ownedInput {3936 if ownedInput {
@@ -3587,7 +3966,7 @@ func (p *stdinPump) run() {
3587 var markerTimer ports.Timer3966 var markerTimer ports.Timer
3588 var markerTimerC <-chan time.Time3967 var markerTimerC <-chan time.Time
3589 flushMarkerPrefix := func() {3968 flushMarkerPrefix := func() {
3590- markers.flush(sink)3969+ markers.flush(routeInput)
3591 markerTimer = nil3970 markerTimer = nil
3592 markerTimerC = nil3971 markerTimerC = nil
3593 }3972 }
@@ -3621,7 +4000,61 @@ func (p *stdinPump) run() {
3621 }4000 }
3622 defer disarmMarkerDeadline()4001 defer disarmMarkerDeadline()
3623 4002
4003+ var pickerTimer ports.Timer
4004+ var pickerTimerC <-chan time.Time
4005+ disarmPickerDeadline := func() {
4006+ if pickerTimer == nil {
4007+ return
4008+ }
4009+ pickerTimer.Stop()
4010+ pickerTimer = nil
4011+ pickerTimerC = nil
4012+ }
4013+ // armPickerDeadline bounds the escape ambiguity window while the picker
4014+ // owns input: a lone ESC is Escape unless a sequence byte follows before
4015+ // the deadline. The withheld prefix never falls back to the session.
4016+ armPickerDeadline := func() {
4017+ disarmPickerDeadline()
4018+ pickerTimer = p.clock.NewTimer(pickerEscapeDeadline)
4019+ pickerTimerC = pickerTimer.C()
4020+ }
4021+ defer disarmPickerDeadline()
4022+ // syncPickerDeadline arms the escape window once per withheld prefix:
4023+ // re-arming on every loop turn would postpone the deadline whenever an
4024+ // unrelated event wakes the pump.
4025+ syncPickerDeadline := func() {
4026+ if !p.picker.hasPending() {
4027+ disarmPickerDeadline()
4028+ return
4029+ }
4030+ if pickerTimer == nil {
4031+ armPickerDeadline()
4032+ }
4033+ }
4034+ // resolveExpiredPickerDeadline applies a deadline that already fired
4035+ // before a later suffix may complete the withheld prefix.
4036+ resolveExpiredPickerDeadline := func() {
4037+ if pickerTimerC == nil {
4038+ return
4039+ }
4040+ select {
4041+ case <-pickerTimerC:
4042+ disarmPickerDeadline()
4043+ if outcome, ok := p.picker.flushPending(); ok {
4044+ p.pickerOutcomes.offer(outcome)
4045+ }
4046+ default:
4047+ }
4048+ }
4049+
3624 for {4050 for {
4051+ // A withheld prefix needs a bounded disambiguation window whichever
4052+ // path withheld it: the palette-marker scanner can flush ordinary
4053+ // bytes into the picker decoder, so the deadline is synced here rather
4054+ // than at one producer site. An already-expired deadline resolves
4055+ // first so a later suffix cannot complete the prefix.
4056+ resolveExpiredPickerDeadline()
4057+ syncPickerDeadline()
3625 var result terminalReadResult4058 var result terminalReadResult
3626 var batch *terminalAutomationRequest4059 var batch *terminalAutomationRequest
3627 select {4060 select {
@@ -3630,6 +4063,11 @@ func (p *stdinPump) run() {
3630 case <-markerTimerC:4063 case <-markerTimerC:
3631 flushMarkerPrefix()4064 flushMarkerPrefix()
3632 continue4065 continue
4066+ case <-pickerTimerC:
4067+ disarmPickerDeadline()
4068+ if outcome, ok := p.picker.flushPending(); ok {
4069+ p.pickerOutcomes.offer(outcome)
4070+ }
3633 case request := <-input.automation:4071 case request := <-input.automation:
3634 input.mu.Lock()4072 input.mu.Lock()
3635 clean := input.consumer == consumer && request.consumer == consumer && input.pending == nil && len(input.residual) == 0 && input.delivering == 04073 clean := input.consumer == consumer && request.consumer == consumer && input.pending == nil && len(input.residual) == 0 && input.delivering == 0
@@ -3647,6 +4085,27 @@ func (p *stdinPump) run() {
3647 activeBatch = batch4085 activeBatch = batch
3648 batchSent.Store(false)4086 batchSent.Store(false)
3649 batchComplete = false4087 batchComplete = false
4088+ outcome, consumed := pickerConsumeOutcome{}, false
4089+ if p.picker != nil {
4090+ outcome, consumed = p.picker.consume(request.record)
4091+ }
4092+ if consumed {
4093+ p.pickerOutcomes.offer(outcome)
4094+ // The open picker loop consumed this admitted op:
4095+ // cursor/search repainted locally or a typed
4096+ // commit/cancel crossed on controlCh. Zero picker
4097+ // bytes enter the PTY pipeline, so the coalescer
4098+ // and fence below are skipped. Completion follows
4099+ // the action path: locally for presentation steps,
4100+ // via PickerFailure or the navigation handoff for
4101+ // commits and cancels.
4102+ request.admitted <- true
4103+ batch.dispatched <- true
4104+ activeBatch = nil
4105+ actionID.Store(0)
4106+ automated = false
4107+ continue
4108+ }
3650 result = request.record4109 result = request.record
3651 automated = true4110 automated = true
3652 actionID.Store(result.actionID)4111 actionID.Store(result.actionID)
@@ -3693,7 +4152,7 @@ func (p *stdinPump) run() {
3693 }, func(light bool) {4152 }, func(light bool) {
3694 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})4153 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
3695 }, func(data []byte) {4154 }, func(data []byte) {
3696- markers.scan(data, sink, func() {4155+ markers.scan(data, routeInput, func() {
3697 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})4156 sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
3698 })4157 })
3699 })4158 })
@@ -3708,9 +4167,9 @@ func (p *stdinPump) run() {
3708 }4167 }
3709 if batch != nil {4168 if batch != nil {
3710 scanner.EndBatch(func(data []byte) {4169 scanner.EndBatch(func(data []byte) {
3711- markers.scan(data, sink, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) })4170+ markers.scan(data, routeInput, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) })
3712 })4171 })
3713- markers.flush(sink)4172+ markers.flush(routeInput)
3714 ok := coalescer.EndBatch() && sendOK.Load()4173 ok := coalescer.EndBatch() && sendOK.Load()
3715 batchComplete = ok && pendingEvents.Load() == 04174 batchComplete = ok && pendingEvents.Load() == 0
3716 applied := make(chan struct{})4175 applied := make(chan struct{})
@@ -3744,7 +4203,7 @@ func (p *stdinPump) run() {
3744 }4203 }
3745 if result.err != nil {4204 if result.err != nil {
3746 disarmMarkerDeadline()4205 disarmMarkerDeadline()
3747- markers.flush(sink)4206+ markers.flush(routeInput)
3748 select {4207 select {
3749 case p.out <- protocol.Detach{}:4208 case p.out <- protocol.Detach{}:
3750 case <-p.ctx.Done():4209 case <-p.ctx.Done():
@@ -3763,10 +4222,26 @@ func (p *stdinPump) run() {
3763 }4222 }
3764 }4223 }
3765 4224
4225+func offerLatestGeometry(out chan domain.Geometry, geometry domain.Geometry) {
4226+ select {
4227+ case out <- geometry:
4228+ return
4229+ default:
4230+ }
4231+ select {
4232+ case <-out:
4233+ default:
4234+ }
4235+ select {
4236+ case out <- geometry:
4237+ default:
4238+ }
4239+}
4240+
3766 // runResize forwards coalesced terminal resize events to the daemon. It4241 // runResize forwards coalesced terminal resize events to the daemon. It
3767 // tolerates an already-closed resize channel (which the terminal adapter4242 // tolerates an already-closed resize channel (which the terminal adapter
3768 // hands back when restore ran before ResizeEvents was first called).4243 // hands back when restore ran before ResizeEvents was first called).
3769-func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- protocol.ClientMessage, sendLease *foregroundSendLease, log *slog.Logger) {4244+func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- protocol.ClientMessage, local chan domain.Geometry, sendLease *foregroundSendLease, log *slog.Logger) {
3770 defer log.Debug("resize pump exited")4245 defer log.Debug("resize pump exited")
3771 for {4246 for {
3772 select {4247 select {
@@ -3783,6 +4258,7 @@ func runResize(ctx context.Context, events <-chan domain.Geometry, out chan<- pr
3783 if !sendLease.send(func() bool {4258 if !sendLease.send(func() bool {
3784 select {4259 select {
3785 case out <- message:4260 case out <- message:
4261+ offerLatestGeometry(local, geometry)
3786 return true4262 return true
3787 case <-ctx.Done():4263 case <-ctx.Done():
3788 return false4264 return false
M internal/usecase/client/client_test.go +171 -1133
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
9 "os"10 "os"
@@ -239,6 +240,64 @@ func isType(typ wire.MsgType) any {
239 return mock.MatchedBy(func(f wire.Frame) bool { return f.Type == typ })240 return mock.MatchedBy(func(f wire.Frame) bool { return f.Type == typ })
240 }241 }
241 242
243+// stubHostRegistry is the test host registry: it resolves endpoints from a
244+// table and projects an empty directory. Run parks until the runner exits, so
245+// the registry lifecycle is joined exactly like production.
246+type stubHostRegistry struct {
247+ resolve func(endpoint string) (ports.RemoteEndpointBinding, error)
248+}
249+
250+func (r stubHostRegistry) ResolveEndpoint(_ context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
251+ if r.resolve == nil {
252+ return ports.RemoteEndpointBinding{}, fmt.Errorf("stub host registry has no resolver for %q", endpoint)
253+ }
254+ return r.resolve(endpoint)
255+}
256+func (stubHostRegistry) Snapshot() ports.RemoteDirectorySnapshot {
257+ return ports.RemoteDirectorySnapshot{}
258+}
259+func (stubHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil }
260+func (stubHostRegistry) RequestReconcile(string) {}
261+func (stubHostRegistry) RegistryChanged() {}
262+func (stubHostRegistry) Run(ctx context.Context) error {
263+ <-ctx.Done()
264+ return nil
265+}
266+
267+// countingHostRegistry records the discovery loop's lifetime, so a test can
268+// prove one loop serves a whole run and is joined before the client returns.
269+type countingHostRegistry struct {
270+ runs atomic.Int32
271+ joined chan struct{}
272+}
273+
274+func (r *countingHostRegistry) ResolveEndpoint(context.Context, string) (ports.RemoteEndpointBinding, error) {
275+ return ports.RemoteEndpointBinding{Dialer: &sequenceDialer{}}, nil
276+}
277+func (*countingHostRegistry) Snapshot() ports.RemoteDirectorySnapshot {
278+ return ports.RemoteDirectorySnapshot{}
279+}
280+func (*countingHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil }
281+func (*countingHostRegistry) RequestReconcile(string) {}
282+func (*countingHostRegistry) RegistryChanged() {}
283+func (r *countingHostRegistry) Run(ctx context.Context) error {
284+ r.runs.Add(1)
285+ <-ctx.Done()
286+ close(r.joined)
287+ return nil
288+}
289+
290+// dialerForEndpoints builds a registry that serves one dialer per endpoint.
291+func dialerForEndpoints(dialers map[string]ports.ClientDialer) stubHostRegistry {
292+ return stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
293+ dialer := dialers[endpoint]
294+ if dialer == nil {
295+ return ports.RemoteEndpointBinding{}, fmt.Errorf("hybrid picker endpoint %q has no dialer", endpoint)
296+ }
297+ return ports.RemoteEndpointBinding{Dialer: dialer}, nil
298+ }}
299+}
300+
242 // transportDialer adapts one already-open test transport to the Runner API.301 // transportDialer adapts one already-open test transport to the Runner API.
243 type transportDialer struct{ transport wire.Transport }302 type transportDialer struct{ transport wire.Transport }
244 303
@@ -557,6 +616,66 @@ func TestKilledSessionReturnsToPreviousLocalRoute(t *testing.T) {
557 require.Equal(t, int32(1), term.restoreCount.Load())616 require.Equal(t, int32(1), term.restoreCount.Load())
558 }617 }
559 618
619+// TestSamePeerSwitchFailurePreservesSourceRouteHistory pins the
620+// precommit-rejection boundary: a daemon SamePeerSwitchFailure leaves the
621+// source attachment unchanged, commits no history, and dials no replacement
622+// transport. The client keeps serving the source route it already owned.
623+func TestSamePeerSwitchFailurePreservesSourceRouteHistory(t *testing.T) {
624+ term := newRunTerminal()
625+ defer term.in.unblock()
626+
627+ sourceTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "source"}
628+ destTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"}
629+ welcome := func(target protocol.ExactSessionTarget) wire.Frame {
630+ return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
631+ SessionID: target.SessionName, SessionName: target.SessionName, ResumeToken: 1,
632+ Capabilities: protocol.CapabilityResume,
633+ CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target},
634+ }))
635+ }
636+ switchSent := make(chan struct{})
637+ var switchOnce sync.Once
638+ active := &recordingTransport{recvs: []recvItem{
639+ {f: welcome(sourceTarget)},
640+ {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
641+ Session: "destination", Intent: protocol.IntentAttach, ExactTarget: &destTarget,
642+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true,
643+ }))},
644+ {f: frameOf(wire.MsgSamePeerSwitchFailure, mustMarshalSamePeerSwitchFailure(protocol.SamePeerSwitchFailure{
645+ RequestID: 1, Code: protocol.SamePeerSwitchUnavailable,
646+ })), wait: switchSent},
647+ {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
648+ }}
649+ active.onSend = func(frame wire.Frame) {
650+ if frame.Type == wire.MsgSamePeerSwitchRequest {
651+ switchOnce.Do(func() { close(switchSent) })
652+ }
653+ }
654+ dialer := &sequenceDialer{trs: []wire.Transport{active}}
655+
656+ err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
657+ Intent: protocol.IntentAttach, SessionName: "source", Origin: protocol.RouteOriginLocal,
658+ OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
659+ })
660+
661+ require.NoError(t, err)
662+ require.Equal(t, int32(1), dialer.calls.Load(), "a rejected same-peer switch must not dial a replacement transport")
663+ var snapshots []protocol.RecentRouteSnapshot
664+ for _, sent := range active.Sends() {
665+ if sent.Type != wire.MsgRecentRouteSnapshot {
666+ continue
667+ }
668+ snapshot, err := wire.UnmarshalRecentRouteSnapshot(sent.Payload)
669+ require.NoError(t, err)
670+ snapshots = append(snapshots, snapshot)
671+ }
672+ require.NotEmpty(t, snapshots, "the welcome commit must publish a route snapshot")
673+ for _, snapshot := range snapshots {
674+ require.Equal(t, sourceTarget, snapshot.ActiveEntry.Target, "a rejected switch must not change the active route")
675+ require.Empty(t, snapshot.Entries, "a rejected switch must not append destination history")
676+ }
677+}
678+
560 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {679 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
561 term := newRunTerminal()680 term := newRunTerminal()
562 defer term.in.unblock()681 defer term.in.unblock()
@@ -568,8 +687,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
568 request := client.AttachRequest{687 request := client.AttachRequest{
569 Intent: protocol.IntentAttach, SessionName: "work", Remote: true,688 Intent: protocol.IntentAttach, SessionName: "work", Remote: true,
570 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,689 RemoteTarget: target, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
571- NavigationCapabilities: protocol.NavigationCapabilityHomePicker,690+ NavigationCapabilities: protocol.NavigationCapabilityInventory,
572- StartupOverlay: protocol.StartupOverlayNone,
573 }691 }
574 tr := &recordingTransport{recvs: []recvItem{692 tr := &recordingTransport{recvs: []recvItem{
575 {f: welcomeFrame(0)},693 {f: welcomeFrame(0)},
@@ -582,8 +700,7 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
582 require.Equal(t, request.SessionName, hello.Name)700 require.Equal(t, request.SessionName, hello.Name)
583 require.Equal(t, target, hello.RemoteTarget)701 require.Equal(t, target, hello.RemoteTarget)
584 require.Equal(t, request.EnvironmentPolicy, hello.EnvironmentPolicy)702 require.Equal(t, request.EnvironmentPolicy, hello.EnvironmentPolicy)
585- require.Equal(t, request.NavigationCapabilities, hello.NavigationCapabilities)703+ require.Equal(t, request.NavigationCapabilities|protocol.NavigationCapabilityInventory, hello.NavigationCapabilities)
586- require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
587 }704 }
588 705
589 func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {706 func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
@@ -625,56 +742,6 @@ func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) {
625 require.Equal(t, &target, helloFromSend(t, second).ExactTarget)742 require.Equal(t, &target, helloFromSend(t, second).ExactTarget)
626 }743 }
627 744
628-func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) {
629- term := newRunTerminal()
630- defer term.in.unblock()
631- localLifecycle := domain.SessionLifecycleID{1}
632- remoteLifecycle := domain.SessionLifecycleID{2}
633- welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
634- return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
635- SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume,
636- CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
637- }))
638- }
639- remoteTarget := domain.RemoteSessionTarget{
640- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
641- SessionName: "work", LiveTabID: "tab-1",
642- }
643- local1 := &recordingTransport{recvs: []recvItem{
644- {f: welcome("local", localLifecycle)},
645- {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
646- Endpoint: "remote", Session: "work", Intent: protocol.IntentAttach,
647- RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
648- }))},
649- }}
650- remote := &recordingTransport{recvs: []recvItem{
651- {f: welcome("work", remoteLifecycle)},
652- {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)},
653- }}
654- local2 := &recordingTransport{recvs: []recvItem{
655- {f: welcome("local", localLifecycle)},
656- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
657- }}
658- localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
659- remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
660- deps := testDependencies(localDialer, term, realClock{}, nil, nil)
661- deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {
662- target := protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
663- return remoteDialer, client.AttachRequest{
664- Intent: protocol.IntentAttach, SessionName: "work", Remote: true,
665- Origin: protocol.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target,
666- EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
667- // Deliberately omit the copied Home capability. The accepted server
668- // action remains valid because Runner retained the concrete home route.
669- }, nil
670- }
671-
672- require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
673- Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
674- }))
675- require.Equal(t, protocol.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
676-}
677-
678 func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) wire.Frame {745 func hybridWelcomeFrame(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
679 return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{746 return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
680 SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume,747 SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: protocol.CapabilityResume,
@@ -694,136 +761,10 @@ func hybridLocalBootstrap(lifecycle domain.SessionLifecycleID, target domain.Rem
694 761
695 func hybridPickerDependencies(localDialer ports.ClientDialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.ClientDialer) client.Dependencies {762 func hybridPickerDependencies(localDialer ports.ClientDialer, term ports.Terminal, clock ports.Clock, remoteByEndpoint map[string]ports.ClientDialer) client.Dependencies {
696 deps := testDependencies(localDialer, term, clock, nil, nil)763 deps := testDependencies(localDialer, term, clock, nil, nil)
697- deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {764+ deps.HostRegistry = dialerForEndpoints(remoteByEndpoint)
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- }
712 return deps765 return deps
713 }766 }
714 767
715-func hybridParkedRequestHandler(requests chan<- protocol.ParkedRouteRequest, requestErrors chan<- error, signals map[protocol.ParkedRouteAction]chan struct{}) func(wire.Frame) {
716- once := make(map[protocol.ParkedRouteAction]*sync.Once, len(signals))
717- for action := range signals {
718- once[action] = &sync.Once{}
719- }
720- return func(frame wire.Frame) {
721- if frame.Type != wire.MsgParkedRouteRequest {
722- return
723- }
724- request, err := wire.UnmarshalParkedRouteRequest(frame.Payload)
725- if err != nil {
726- if requestErrors != nil {
727- requestErrors <- err
728- }
729- return
730- }
731- if requests != nil {
732- requests <- request
733- }
734- if signal := signals[request.Action]; signal != nil {
735- once[request.Action].Do(func() { close(signal) })
736- }
737- }
738-}
739-
740-func TestHybridPickerSameHostSwitchReusesRemoteTransport(t *testing.T) {
741- term := newRunTerminal()
742- defer term.in.unblock()
743-
744- localLifecycle := domain.SessionLifecycleID{1}
745- remoteSourceLifecycle := domain.SessionLifecycleID{2}
746- remoteTargetLifecycle := domain.SessionLifecycleID{3}
747- sourceTarget := domain.RemoteSessionTarget{
748- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteSourceLifecycle,
749- SessionName: "source", LiveTabID: "source-tab",
750- }
751- targetTarget := domain.RemoteSessionTarget{
752- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteTargetLifecycle,
753- SessionName: "target", LiveTabID: "target-tab",
754- }
755- localInitial := hybridLocalBootstrap(localLifecycle, sourceTarget)
756- parkSent := make(chan struct{})
757- switchSent := make(chan struct{})
758- parkedRequests := make(chan protocol.ParkedRouteRequest, 2)
759- parkedRequestErrors := make(chan error, 2)
760- remote := &recordingTransport{recvs: []recvItem{
761- {f: hybridWelcomeFrame("source", remoteSourceLifecycle)},
762- {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)},
763- {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: parkSent},
764- {f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: remoteTargetLifecycle, SessionName: "target"}})), wait: switchSent},
765- {f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteSwitched}))},
766- {f: frameOf(wire.MsgOutput, mustMarshalOutput(protocol.Output{Epoch: 2, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("target frame")}))},
767- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
768- }}
769- remote.onSend = hybridParkedRequestHandler(parkedRequests, parkedRequestErrors, map[protocol.ParkedRouteAction]chan struct{}{
770- protocol.ParkedRoutePrepare: parkSent,
771- protocol.ParkedRouteSwitch: switchSent,
772- })
773- localPicker := &recordingTransport{recvs: []recvItem{
774- {f: hybridWelcomeFrame("local", localLifecycle)},
775- {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
776- Endpoint: "remote", Session: "target", Intent: protocol.IntentAttach,
777- RemoteTarget: &targetTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
778- }))},
779- }}
780- var resizeOnce sync.Once
781- localPicker.onSend = func(frame wire.Frame) {
782- if frame.Type == wire.MsgHello {
783- resizeOnce.Do(func() { term.setSize(domain.Size{Cols: 100, Rows: 40}) })
784- }
785- }
786- localDialer := &sequenceDialer{trs: []wire.Transport{localInitial, localPicker}}
787- remoteDialer := &sequenceDialer{trs: []wire.Transport{markedDatagramTransport{Transport: remote}}}
788- deps := hybridPickerDependencies(localDialer, term, realClock{}, map[string]ports.ClientDialer{"remote": remoteDialer})
789-
790- err := runTestClient(context.Background(), deps, client.AttachRequest{
791- Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
792- })
793-
794- require.NoError(t, err)
795- select {
796- case requestErr := <-parkedRequestErrors:
797- require.NoError(t, requestErr)
798- default:
799- }
800- prepareRequest := <-parkedRequests
801- switchRequest := <-parkedRequests
802- require.Equal(t, protocol.ParkedRoutePrepare, prepareRequest.Action)
803- require.Equal(t, protocol.ParkedRouteSwitch, switchRequest.Action)
804- require.NotNil(t, switchRequest.Target)
805- require.Equal(t, targetTarget, *switchRequest.Target)
806- frames := remote.Sends()
807- switchIndex := -1
808- for i, frame := range frames {
809- if frame.Type != wire.MsgParkedRouteRequest {
810- continue
811- }
812- request, requestErr := wire.UnmarshalParkedRouteRequest(frame.Payload)
813- require.NoError(t, requestErr)
814- if request.Action == protocol.ParkedRouteSwitch {
815- switchIndex = i
816- break
817- }
818- }
819- require.Positive(t, switchIndex)
820- require.Equal(t, wire.MsgResize, frames[switchIndex-1].Type, "current size must precede the parked switch")
821- resize, resizeErr := wire.UnmarshalResize(frames[switchIndex-1].Payload)
822- require.NoError(t, resizeErr)
823- require.Equal(t, domain.Size{Cols: 100, Rows: 40}, resize.Size)
824- require.Equal(t, int32(1), remoteDialer.calls.Load(), "same-host picker switch must retain the authenticated remote transport")
825-}
826-
827 func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) {768 func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) {
828 term := newRunTerminal()769 term := newRunTerminal()
829 defer term.in.unblock()770 defer term.in.unblock()
@@ -879,381 +820,10 @@ func TestHybridBackSessionSamePeerOfferReusesRemoteTransport(t *testing.T) {
879 require.Equal(t, int32(1), remoteDialer.calls.Load(), "BCK must retain the authenticated remote transport")820 require.Equal(t, int32(1), remoteDialer.calls.Load(), "BCK must retain the authenticated remote transport")
880 }821 }
881 822
882-func TestHybridPickerLocalSelectionCommitsLocalRoute(t *testing.T) {823+// TestHybridStdioHomeOpenSkipsParking pins the SSH-stdio pairing: a home
883- term := newRunTerminal()824+// directive on a non-datagram serving transport must not send a parked-route
884- defer term.in.unblock()825+// Prepare. The client closes the serving connection and dials the retained
885-826+// home route instead, and Back redials the remote endpoint from scratch.
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-}
1257 827
1258 func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {828 func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1259 tests := []struct {829 tests := []struct {
@@ -1286,17 +856,15 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) {
1286 sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}}856 sourceDialer := &sequenceDialer{trs: []wire.Transport{initial, restored}}
1287 targetDialer := &sequenceDialer{errs: []error{tt.dialErr}}857 targetDialer := &sequenceDialer{errs: []error{tt.dialErr}}
1288 deps := testDependencies(sourceDialer, term, realClock{}, nil, nil)858 deps := testDependencies(sourceDialer, term, realClock{}, nil, nil)
1289- deps.AttachHandoff = func(got protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {859+ resolved := 0
1290- require.Equal(t, target, got)860+ deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
861+ resolved++
862+ require.Equal(t, target.Endpoint, endpoint)
1291 if tt.handoffErr != nil {863 if tt.handoffErr != nil {
1292- return nil, client.AttachRequest{}, tt.handoffErr864+ return ports.RemoteEndpointBinding{}, tt.handoffErr
1293 }865 }
1294- return targetDialer, client.AttachRequest{866+ return ports.RemoteEndpointBinding{Dialer: targetDialer}, nil
1295- Intent: protocol.IntentNew, SessionName: "example", Remote: true,867+ }}
1296- Origin: protocol.RouteOriginDiscovery, OriginKey: "host-a", HostLabel: "host-a",
1297- EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
1298- }, nil
1299- }
1300 868
1301 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{869 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
1302 Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local",870 Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginLocal, OriginKey: "local",
@@ -1349,138 +917,41 @@ func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) {
1349 }}917 }}
1350 local2 := &recordingTransport{recvs: []recvItem{918 local2 := &recordingTransport{recvs: []recvItem{
1351 {f: welcome("misc", localLifecycle)},919 {f: welcome("misc", localLifecycle)},
1352- {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{920+ {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{
1353- SnapshotGeneration: 3, Key: 2, Generation: 2,921+ 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",
1446 }))},922 }))},
1447- {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)},
1448 }}923 }}
1449- remote3 := &recordingTransport{recvs: []recvItem{924+ remote2 := &recordingTransport{recvs: []recvItem{
1450- {f: welcome("remote-new", remoteNewLifecycle, 22)},925+ {f: welcome("created", remoteLifecycle)},
1451 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},926 {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
1452 }}927 }}
1453- localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2, local3}}928+ localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}}
1454- remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2, remote3}}929+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}}
1455-
1456 deps := testDependencies(localDialer, term, realClock{}, nil, nil)930 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
1457- deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {931+ // The remote return attach relays the palette inventory from the retained
1458- require.Equal(t, remoteHandoff, target)932+ // local home route; without a control source it must not claim the
1459- return remoteDialer, client.AttachRequest{933+ // capability. The mock admits no dial: this route receives no demand.
1460- Intent: protocol.IntentAttach, SessionName: target.Session, Remote: true,934+ deps.LocalControlDialer = portsmocks.NewMockClientDialer(t)
1461- Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint,935+ deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
1462- RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,936+ require.Equal(t, createRemote.Endpoint, endpoint)
1463- }, nil937+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
1464- }938+ }}
1465 939
1466 err := runTestClient(context.Background(), deps, client.AttachRequest{940 err := runTestClient(context.Background(), deps, client.AttachRequest{
1467- Intent: protocol.IntentAttach, SessionName: "test", Origin: protocol.RouteOriginLocal, OriginKey: "local",941+ Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local",
1468 })942 })
1469- remoteHello := helloFromSend(t, remote2)
1470 require.NoError(t, err)943 require.NoError(t, err)
1471-944+ require.Equal(t, int32(2), remoteDialer.calls.Load())
1472- require.Equal(t, protocol.NavigationCapabilityHomePicker, remoteHello.NavigationCapabilities)945+ createHello := helloFromSend(t, remote1)
1473- remoteNewHello := helloFromSend(t, remote3)946+ require.Equal(t, protocol.IntentNew, createHello.Intent)
1474- require.Equal(t, "remote-new", remoteNewHello.Name)947+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, createHello.EnvironmentPolicy)
1475- require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, remoteNewHello.ExactTarget)948+ returnHello := helloFromSend(t, remote2)
1476-}949+ require.Equal(t, protocol.IntentResume, returnHello.Intent)
1477-950+ require.Equal(t, "created", returnHello.Name)
1478-func navigationDirectiveFrame(action protocol.NavigationAction) wire.Frame {951+ require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget)
1479- directive := protocol.NavigationDirective{Action: action}952+ require.Nil(t, returnHello.RemoteTarget)
1480- if action == protocol.NavigationOpenHomePicker {953+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy)
1481- directive.LeaseID = protocol.ParkedRouteLeaseID{1}954+ require.Equal(t, protocol.NavigationCapabilityInventory, returnHello.NavigationCapabilities)
1482- }
1483- return frameOf(wire.MsgNavigationAction, wire.MarshalNavigationDirective(directive))
1484 }955 }
1485 956
1486 func mustMarshalCommittedIdentity(identity protocol.CommittedRouteIdentity) []byte {957 func mustMarshalCommittedIdentity(identity protocol.CommittedRouteIdentity) []byte {
@@ -1507,6 +978,14 @@ func mustMarshalRoutePosition(position protocol.RoutePosition) []byte {
1507 return payload978 return payload
1508 }979 }
1509 980
981+func mustMarshalSamePeerSwitchFailure(failure protocol.SamePeerSwitchFailure) []byte {
982+ payload, err := wire.MarshalSamePeerSwitchFailure(failure)
983+ if err != nil {
984+ panic(err)
985+ }
986+ return payload
987+}
988+
1510 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {989 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
1511 var out bytes.Buffer990 var out bytes.Buffer
1512 var restoreCount atomic.Int32991 var restoreCount atomic.Int32
@@ -2217,451 +1696,6 @@ func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testin
2217 require.Nil(t, reconnectHello.RemoteTarget)1696 require.Nil(t, reconnectHello.RemoteTarget)
2218 }1697 }
2219 1698
2220-func TestAttachRememberRemoteHost(t *testing.T) {
2221- tests := []struct {
2222- name string
2223- request client.AttachRequest
2224- remember func() error
2225- recv []recvItem
2226- preWelcomeReject bool
2227- wantCalled bool
2228- wantErr bool
2229- }{
2230- {
2231- name: "remote success",
2232- request: client.AttachRequest{
2233- Intent: protocol.IntentAttach,
2234- SessionName: "main",
2235- Remote: true,
2236- },
2237- remember: func() error { return nil },
2238- recv: []recvItem{
2239- {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2240- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2241- },
2242- wantCalled: true,
2243- },
2244- {
2245- name: "callback failure does not fail attach",
2246- request: client.AttachRequest{
2247- Intent: protocol.IntentAttach,
2248- SessionName: "main",
2249- Remote: true,
2250- },
2251- remember: func() error { return errors.New("disk full") },
2252- recv: []recvItem{
2253- {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2254- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2255- },
2256- wantCalled: true,
2257- },
2258- {
2259- name: "local attach does not call callback",
2260- request: client.AttachRequest{
2261- Intent: protocol.IntentAttach,
2262- SessionName: "main",
2263- Remote: false,
2264- },
2265- remember: func() error {
2266- t.Fatal("remember callback must not run for local attach")
2267- return nil
2268- },
2269- recv: []recvItem{
2270- {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2271- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2272- },
2273- },
2274- {
2275- name: "rejected before welcome does not call callback",
2276- request: client.AttachRequest{
2277- Intent: protocol.IntentAttach,
2278- SessionName: "main",
2279- Remote: true,
2280- },
2281- remember: func() error {
2282- t.Fatal("remember callback must not run before welcome")
2283- return nil
2284- },
2285- recv: []recvItem{
2286- {f: frameOf(wire.MsgError, wire.MarshalErrorMsg(protocol.ErrorMsg{Code: protocol.ErrVersionMismatch, Text: "version mismatch"}))},
2287- },
2288- preWelcomeReject: true,
2289- wantErr: true,
2290- },
2291- {
2292- name: "post-welcome error still uses happy terminal fixture",
2293- request: client.AttachRequest{
2294- Intent: protocol.IntentAttach,
2295- SessionName: "main",
2296- Remote: true,
2297- },
2298- remember: func() error { return nil },
2299- recv: []recvItem{
2300- {f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2301- {err: errors.New("connection reset")},
2302- },
2303- wantCalled: true,
2304- wantErr: true,
2305- },
2306- }
2307-
2308- for _, tt := range tests {
2309- t.Run(tt.name, func(t *testing.T) {
2310- var tm *portsmocks.MockTerminal
2311- var unblockIn func()
2312- if tt.preWelcomeReject {
2313- tm = portsmocks.NewMockTerminal(t)
2314- tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2315- // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
2316- } else {
2317- var out bytes.Buffer
2318- var restoreCount atomic.Int32
2319- resizeCh := make(chan domain.Geometry)
2320- var in *blockingReader
2321- tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)
2322- unblockIn = in.unblock
2323- }
2324- if unblockIn != nil {
2325- defer unblockIn()
2326- }
2327-
2328- var called atomic.Bool
2329- learnerDone := make(chan struct{})
2330- rememberHook := func() error {
2331- called.Store(true)
2332- defer close(learnerDone)
2333- if tt.remember != nil {
2334- return tt.remember()
2335- }
2336- return nil
2337- }
2338-
2339- tr := newMockClientConnection(t)
2340- if !tt.preWelcomeReject {
2341- tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe()
2342- }
2343- tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()
2344- if tt.preWelcomeReject {
2345- tr.EXPECT().Recv().Return(tt.recv[0].f, tt.recv[0].err).Once()
2346- } else {
2347- unblock := scriptRecv(tr, tt.recv...)
2348- defer unblock()
2349- }
2350- tr.EXPECT().Close().Return(nil).Once()
2351-
2352- deps := attachTestDependencies(tr, tm, realClock{})
2353- learner := portsmocks.NewMockRemoteHostLearner(t)
2354- if tt.wantCalled {
2355- learner.EXPECT().RememberRemoteHost().RunAndReturn(rememberHook).Once()
2356- }
2357- deps.RemoteHostLearner = learner
2358-
2359- err := runTestClient(context.Background(), deps, tt.request)
2360- if tt.wantCalled {
2361- select {
2362- case <-learnerDone:
2363- case <-time.After(2 * time.Second):
2364- t.Fatal("remote host learner did not complete")
2365- }
2366- }
2367- if tt.wantErr {
2368- require.Error(t, err)
2369- } else {
2370- require.NoError(t, err)
2371- }
2372- require.Equal(t, tt.wantCalled, called.Load())
2373- })
2374- }
2375-}
2376-
2377-func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
2378- term := newRunTerminal()
2379- defer term.in.unblock()
2380-
2381- var rememberCalls atomic.Int32
2382- learnerDone := make(chan struct{})
2383-
2384- tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
2385- tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}}}
2386- d := &sequenceDialer{trs: []wire.Transport{tr1, tr2}}
2387-
2388- deps := testDependencies(d, term, realClock{}, nil, nil)
2389- learner := portsmocks.NewMockRemoteHostLearner(t)
2390- learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
2391- rememberCalls.Add(1)
2392- close(learnerDone)
2393- return nil
2394- }).Once()
2395- deps.RemoteHostLearner = learner
2396- err := runTestClient(context.Background(), deps, client.AttachRequest{
2397- Intent: protocol.IntentAttach,
2398- SessionName: "main",
2399- Remote: true,
2400- })
2401- require.NoError(t, err)
2402- select {
2403- case <-learnerDone:
2404- case <-time.After(2 * time.Second):
2405- t.Fatal("remote host learner did not complete")
2406- }
2407- require.Equal(t, int32(1), rememberCalls.Load())
2408- require.Equal(t, int32(2), d.calls.Load())
2409-}
2410-
2411-func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {
2412- var out bytes.Buffer
2413- var restoreCount atomic.Int32
2414- resizeCh := make(chan domain.Geometry)
2415- tm := portsmocks.NewMockTerminal(t)
2416- tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2417- enteredRaw := make(chan struct{})
2418- tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {
2419- restoreCount.Add(1)
2420- return nil
2421- }, nil).Once()
2422- in := newBlockingReader()
2423- tm.EXPECT().In().Return(in).Maybe()
2424- tm.EXPECT().Out().Return(&out).Maybe()
2425- tm.EXPECT().Flush().Return(nil).Maybe()
2426- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
2427- defer in.unblock()
2428-
2429- learnerStarted := make(chan struct{})
2430- releaseLearner := make(chan struct{})
2431- learnerDone := make(chan struct{})
2432-
2433- tr := newMockClientConnection(t)
2434- tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe()
2435- tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()
2436- unblock := scriptRecv(tr,
2437- recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2438- recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2439- )
2440- defer unblock()
2441- tr.EXPECT().Close().Return(nil).Once()
2442-
2443- deps := attachTestDependencies(tr, tm, realClock{})
2444- learner := portsmocks.NewMockRemoteHostLearner(t)
2445- learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
2446- close(learnerStarted)
2447- <-releaseLearner
2448- close(learnerDone)
2449- return nil
2450- }).Once()
2451- deps.RemoteHostLearner = learner
2452-
2453- errCh := make(chan error, 1)
2454- go func() {
2455- errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
2456- Intent: protocol.IntentAttach,
2457- SessionName: "main",
2458- Remote: true,
2459- })
2460- }()
2461-
2462- select {
2463- case <-learnerStarted:
2464- case <-time.After(2 * time.Second):
2465- t.Fatal("remote host learner did not start")
2466- }
2467- select {
2468- case <-enteredRaw:
2469- case <-time.After(2 * time.Second):
2470- t.Fatal("attach did not enter raw mode while remote host learner was blocked")
2471- }
2472- select {
2473- case err := <-errCh:
2474- t.Fatalf("run returned before remote host learner completed: %v", err)
2475- default:
2476- }
2477- close(releaseLearner)
2478- select {
2479- case <-learnerDone:
2480- case <-time.After(2 * time.Second):
2481- t.Fatal("remote host learner did not complete after release")
2482- }
2483- select {
2484- case err := <-errCh:
2485- require.NoError(t, err)
2486- case <-time.After(2 * time.Second):
2487- t.Fatal("run did not return after remote host learner completed")
2488- }
2489-}
2490-
2491-func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) {
2492- var out bytes.Buffer
2493- var restoreCount atomic.Int32
2494- restored := make(chan struct{})
2495- resizeCh := make(chan domain.Geometry)
2496- tm := portsmocks.NewMockTerminal(t)
2497- tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2498- tm.EXPECT().EnterRaw().Return(func() error {
2499- restoreCount.Add(1)
2500- close(restored)
2501- return nil
2502- }, nil).Once()
2503- in := newBlockingReader()
2504- tm.EXPECT().In().Return(in).Maybe()
2505- tm.EXPECT().Out().Return(&out).Maybe()
2506- tm.EXPECT().Flush().Return(nil).Maybe()
2507- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
2508- defer in.unblock()
2509-
2510- learnerStarted := make(chan struct{})
2511- releaseLearner := make(chan struct{})
2512- learnerDone := make(chan struct{})
2513-
2514- tr := newMockClientConnection(t)
2515- tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe()
2516- tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()
2517- unblock := scriptRecv(tr,
2518- recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2519- recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2520- )
2521- defer unblock()
2522- tr.EXPECT().Close().Return(nil).Once()
2523-
2524- deps := attachTestDependencies(tr, tm, realClock{})
2525- learner := portsmocks.NewMockRemoteHostLearner(t)
2526- learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
2527- close(learnerStarted)
2528- <-releaseLearner
2529- close(learnerDone)
2530- return nil
2531- }).Once()
2532- deps.RemoteHostLearner = learner
2533-
2534- errCh := make(chan error, 1)
2535- go func() {
2536- errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
2537- Intent: protocol.IntentAttach,
2538- SessionName: "main",
2539- Remote: true,
2540- })
2541- }()
2542-
2543- select {
2544- case <-learnerStarted:
2545- case <-time.After(2 * time.Second):
2546- t.Fatal("remote host learner did not start")
2547- }
2548- select {
2549- case <-restored:
2550- case <-time.After(2 * time.Second):
2551- t.Fatal("terminal was not restored while remote host learner was blocked")
2552- }
2553- require.Equal(t, int32(1), restoreCount.Load())
2554- select {
2555- case err := <-errCh:
2556- t.Fatalf("run returned before remote host learner completed: %v", err)
2557- default:
2558- }
2559- close(releaseLearner)
2560- select {
2561- case <-learnerDone:
2562- case <-time.After(2 * time.Second):
2563- t.Fatal("remote host learner did not complete after release")
2564- }
2565- select {
2566- case err := <-errCh:
2567- require.NoError(t, err)
2568- case <-time.After(2 * time.Second):
2569- t.Fatal("run did not return after remote host learner completed")
2570- }
2571-}
2572-
2573-func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) {
2574- var out bytes.Buffer
2575- var restoreCount atomic.Int32
2576- restored := make(chan struct{})
2577- resizeCh := make(chan domain.Geometry)
2578- tm := portsmocks.NewMockTerminal(t)
2579- tm.EXPECT().Geometry().Return(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil).Once()
2580- tm.EXPECT().EnterRaw().Return(func() error {
2581- restoreCount.Add(1)
2582- close(restored)
2583- return nil
2584- }, nil).Once()
2585- in := newBlockingReader()
2586- tm.EXPECT().In().Return(in).Maybe()
2587- tm.EXPECT().Out().Return(&out).Maybe()
2588- tm.EXPECT().Flush().Return(nil).Maybe()
2589- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
2590- defer in.unblock()
2591-
2592- learnerStarted := make(chan struct{})
2593- blockLearner := make(chan struct{})
2594- defer close(blockLearner)
2595-
2596- shutdownTimerC := make(chan time.Time, 1)
2597- shutdownTimerCreated := make(chan struct{})
2598- shutdownTimer := portsmocks.NewMockTimer(t)
2599- shutdownTimer.EXPECT().C().Return((<-chan time.Time)(shutdownTimerC)).Once()
2600- shutdownTimer.EXPECT().Stop().Return(true).Once()
2601- clock := portsmocks.NewMockClock(t)
2602- clock.EXPECT().Now().Return(time.Now()).Maybe()
2603- clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
2604- if d == time.Second {
2605- close(shutdownTimerCreated)
2606- return shutdownTimer
2607- }
2608- return realClock{}.NewTimer(d)
2609- }).Maybe()
2610-
2611- tr := newMockClientConnection(t)
2612- tr.EXPECT().Send(isType(wire.MsgTheme)).Return(nil).Maybe()
2613- tr.EXPECT().Send(isType(wire.MsgHello)).Return(nil).Once()
2614- unblock := scriptRecv(tr,
2615- recvItem{f: frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{SessionID: "s1"}))},
2616- recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
2617- )
2618- defer unblock()
2619- tr.EXPECT().Close().Return(nil).Once()
2620-
2621- deps := attachTestDependencies(tr, tm, clock)
2622- learner := portsmocks.NewMockRemoteHostLearner(t)
2623- learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
2624- close(learnerStarted)
2625- <-blockLearner
2626- return nil
2627- }).Once()
2628- deps.RemoteHostLearner = learner
2629-
2630- errCh := make(chan error, 1)
2631- go func() {
2632- errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
2633- Intent: protocol.IntentAttach,
2634- SessionName: "main",
2635- Remote: true,
2636- })
2637- }()
2638-
2639- select {
2640- case <-learnerStarted:
2641- case <-time.After(2 * time.Second):
2642- t.Fatal("remote host learner did not start")
2643- }
2644- select {
2645- case <-restored:
2646- case <-time.After(2 * time.Second):
2647- t.Fatal("terminal was not restored while remote host learner was stalled")
2648- }
2649- require.Equal(t, int32(1), restoreCount.Load())
2650-
2651- select {
2652- case <-shutdownTimerCreated:
2653- case <-time.After(2 * time.Second):
2654- t.Fatal("remote host learner shutdown timer was not created")
2655- }
2656- shutdownTimerC <- time.Now()
2657- select {
2658- case err := <-errCh:
2659- require.NoError(t, err)
2660- case <-time.After(2 * time.Second):
2661- t.Fatal("run did not return after remote host learner shutdown timer fired")
2662- }
2663-}
2664-
2665 func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) {1699 func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) {
2666 term := newRunTerminal()1700 term := newRunTerminal()
2667 defer term.in.unblock()1701 defer term.in.unblock()
@@ -2673,3 +1707,7 @@ func TestRunExitsCleanlyWhenKilledSessionHasNoPriorRoute(t *testing.T) {
2673 require.Equal(t, int32(1), d.calls.Load())1707 require.Equal(t, int32(1), d.calls.Load())
2674 require.Equal(t, int32(1), term.restoreCount.Load())1708 require.Equal(t, int32(1), term.restoreCount.Load())
2675 }1709 }
1710+
1711+// TestRunnerOwnsOneRegistryLoopPerRun pins the discovery lifetime: the runner
1712+// starts the registry once, keeps it across a handoff and a reconnect, and
1713+// joins it before it returns, including on the failure path.
A internal/usecase/client/clipboard_foreground_test.go +107 -0
@@ -0,0 +1,107 @@
1+package client
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/internal/protocol"
16+)
17+
18+// TestTerminalInputPumpClipboardReadBlockedDoesNotHoldForegroundSwitch pins
19+// the P2.1 foreground-ownership boundary: a slow workstation clipboard read
20+// (e.g. a hung wl-paste) triggered by Ctrl+V must abort when the foreground
21+// is stopped for a route switch, so the pump cannot hold the switch waiting
22+// and a late result cannot reach a replacement owner.
23+func TestTerminalInputPumpClipboardReadBlockedDoesNotHoldForegroundSwitch(t *testing.T) {
24+ input := newTerminalInputPump(nil)
25+ input.enqueue(terminalReadResult{data: []byte{ctrlV}}, 1)
26+
27+ started := make(chan struct{})
28+ reader := portsmocks.NewMockClipboardReader(t)
29+ reader.EXPECT().ReadImage(mock.Anything).RunAndReturn(func(ctx context.Context) (string, []byte, error) {
30+ close(started)
31+ <-ctx.Done()
32+ // Adapters map cancellation onto ErrNoClipboardImage so a cancelled
33+ // read silently forwards the keystroke; mirror that contract here.
34+ return "", nil, ports.ErrNoClipboardImage
35+ }).Once()
36+
37+ var activeGeneration atomic.Uint64
38+ ctx, cancel := context.WithCancel(context.Background())
39+ out := make(chan protocol.ClientMessage, 16)
40+ done := make(chan struct{})
41+ go func() {
42+ (&stdinPump{
43+ ctx: ctx, cancel: cancel, input: input,
44+ out: out, clock: newAttachPaletteClock(),
45+ clipboard: reader,
46+ logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent, 32),
47+ activeGeneration: &activeGeneration,
48+ sendLease: newForegroundSendLease(),
49+ }).run()
50+ close(done)
51+ }()
52+
53+ select {
54+ case <-started:
55+ case <-time.After(2 * time.Second):
56+ t.Fatal("clipboard read did not start")
57+ }
58+ // Route switch: stopForeground cancels the foreground context, then
59+ // waits for the pump. A read bound to the foreground must release it.
60+ cancel()
61+ requireSignal(t, done, "cancelled stdin pump stayed blocked in a clipboard read after foreground stop")
62+
63+ // The cancelled read resolves through the standard ErrNoClipboardImage
64+ // contract. Delivery races the foreground cancellation: either the
65+ // Ctrl+V won the send race and sits in out as ordinary input, or the
66+ // pump surrendered it into the input residual for the replacement
67+ // foreground. Both outcomes preserve the keystroke; neither may
68+ // produce an image or a notice to a replacement owner.
69+ forwarded := false
70+ for {
71+ select {
72+ case message := <-out:
73+ switch got := message.(type) {
74+ case protocol.Input:
75+ require.Equal(t, []byte{ctrlV}, got.Data, "cancelled Ctrl+V must be forwarded verbatim as ordinary input")
76+ forwarded = true
77+ case protocol.ImagePush:
78+ t.Fatal("cancelled clipboard read delivered an image to the replacement owner")
79+ case protocol.ClientNotice:
80+ t.Fatal("cancelled clipboard read delivered a notice to the replacement owner")
81+ default:
82+ t.Fatalf("unexpected message after cancelled clipboard read: %T", message)
83+ }
84+ if forwarded {
85+ goto drained
86+ }
87+ default:
88+ goto drained
89+ }
90+ }
91+drained:
92+ if !forwarded {
93+ // Send lost the race to cancellation: the byte must wait in the
94+ // input residual for the replacement foreground, not vanish.
95+ replacement := input.claim()
96+ defer input.revoke(replacement)
97+ got, ok := input.take(context.Background(), replacement)
98+ require.True(t, ok, "cancelled Ctrl+V was neither forwarded nor preserved: keystroke lost")
99+ require.Equal(t, []byte{ctrlV}, got.data)
100+ input.ack(replacement)
101+ }
102+ select {
103+ case message := <-out:
104+ t.Fatalf("cancelled clipboard read delivered a second frame: %T", message)
105+ default:
106+ }
107+}
M internal/usecase/client/clipboard_intercept.go +13 -1
@@ -26,6 +26,14 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB
26 // text may legitimately contain it) — is forwarded to next unchanged, so it26 // text may legitimately contain it) — is forwarded to next unchanged, so it
27 // still gets the coalescer's marker-splitting treatment.27 // still gets the coalescer's marker-splitting treatment.
28 type clipboardIntercept struct {28 type clipboardIntercept struct {
29+ // ctx is the foreground lifetime owning this scanner. A slow
30+ // workstation read (e.g. a hung wl-paste) must abort when the
31+ // foreground is stopped for a route switch, so the pump cannot hold
32+ // the switch waiting and a late result cannot reach a replacement
33+ // owner. Adapters map cancellation onto ErrNoClipboardImage, which
34+ // takes the existing forward path: the keystroke stays ordinary
35+ // input and follows the standard undelivered-byte handoff.
36+ ctx context.Context
29 coalescer *pasteCoalescer37 coalescer *pasteCoalescer
30 reader ports.ClipboardReader38 reader ports.ClipboardReader
31 log *slog.Logger39 log *slog.Logger
@@ -97,7 +105,11 @@ func pasteMarkerBoundary(data []byte) int {
97 }105 }
98 106
99 func (c *clipboardIntercept) handleCtrlV() {107 func (c *clipboardIntercept) handleCtrlV() {
100- mime, data, err := c.reader.ReadImage(context.Background())108+ ctx := c.ctx
109+ if ctx == nil {
110+ ctx = context.Background()
111+ }
112+ mime, data, err := c.reader.ReadImage(ctx)
101 if err != nil {113 if err != nil {
102 if !errors.Is(err, ports.ErrNoClipboardImage) {114 if !errors.Is(err, ports.ErrNoClipboardImage) {
103 c.log.Warn("clipboard image read failed", "err", err)115 c.log.Warn("clipboard image read failed", "err", err)
D internal/usecase/client/home_picker_context_test.go +0 -198
@@ -1,198 +0,0 @@
1-package client_test
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/internal/protocol"
11- "github.com/bnema/vev/internal/protocol/wire"
12- "github.com/bnema/vev/internal/usecase/client"
13-)
14-
15-func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) {
16- for _, tc := range []struct {
17- name string
18- udp bool
19- rename bool
20- selectLocal bool
21- retireLocal bool
22- }{
23- {name: "UDP", udp: true},
24- {name: "stdio"},
25- {name: "UDP local deletion", udp: true, retireLocal: true},
26- {name: "stdio local deletion", retireLocal: true},
27- {name: "UDP backing session renamed", udp: true, rename: true},
28- {name: "stdio backing session renamed", rename: true},
29- {name: "stdio select new local destination", selectLocal: true},
30- {name: "UDP select new local destination", udp: true, selectLocal: true},
31- } {
32- t.Run(tc.name, func(t *testing.T) {
33- term := newRunTerminal()
34- defer term.in.unblock()
35- localLifecycle := domain.SessionLifecycleID{1}
36- remoteLifecycle := domain.SessionLifecycleID{2}
37- target := domain.RemoteSessionTarget{
38- Endpoint: "igor", DisplayOrigin: "igor", LifecycleID: remoteLifecycle,
39- SessionName: "misc", LiveTabID: "remote-tab",
40- }
41- initial := hybridLocalBootstrap(localLifecycle, target)
42- remote := &recordingTransport{recvs: []recvItem{
43- {f: hybridWelcomeFrame("misc", remoteLifecycle)},
44- {f: navigationDirectiveFrame(protocol.NavigationOpenHomePicker)},
45- }}
46- remoteReturn := &recordingTransport{recvs: []recvItem{
47- {f: hybridWelcomeFrame("misc", remoteLifecycle)},
48- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
49- }}
50- remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}}
51- if tc.udp {
52- prepareSent, resumeSent := make(chan struct{}), make(chan struct{})
53- var retiredSnapshot chan struct{}
54- if tc.retireLocal {
55- retiredSnapshot = make(chan struct{})
56- }
57- remote.onSend = hybridParkedRequestHandler(nil, nil, map[protocol.ParkedRouteAction]chan struct{}{
58- protocol.ParkedRoutePrepare: prepareSent,
59- protocol.ParkedRouteResume: resumeSent,
60- })
61- if tc.retireLocal {
62- parkedHandler := remote.onSend
63- remote.onSend = func(frame wire.Frame) {
64- parkedHandler(frame)
65- if frame.Type == wire.MsgRecentRouteSnapshot {
66- snapshot, err := wire.UnmarshalRecentRouteSnapshot(frame.Payload)
67- if err == nil && snapshot.Generation > 2 && len(snapshot.Entries) == 0 {
68- select {
69- case <-retiredSnapshot:
70- default:
71- close(retiredSnapshot)
72- }
73- }
74- }
75- }
76- }
77- remote.recvs = append(remote.recvs,
78- recvItem{f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})), wait: prepareSent},
79- recvItem{f: frameOf(wire.MsgParkedRouteResponse, wire.MarshalParkedRouteResponse(protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteResumed})), wait: resumeSent},
80- recvItem{f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})), wait: retiredSnapshot},
81- )
82- remoteDialer.trs = []wire.Transport{markedDatagramTransport{Transport: remote}}
83- }
84- localPicker := &recordingTransport{recvs: []recvItem{{f: hybridWelcomeFrame("local", localLifecycle)}}}
85- if tc.rename {
86- localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{
87- Target: protocol.ExactSessionTarget{LifecycleID: localLifecycle, SessionName: "renamed-local"},
88- }))})
89- }
90- var localRetiredSnapshot chan struct{}
91- if tc.retireLocal {
92- localRetiredSnapshot = make(chan struct{})
93- localPicker.onSend = func(frame wire.Frame) {
94- if frame.Type == wire.MsgRecentRouteSnapshot {
95- snapshot, err := wire.UnmarshalRecentRouteSnapshot(frame.Payload)
96- if err == nil && snapshot.Generation > 2 && len(snapshot.Entries) == 0 {
97- select {
98- case <-localRetiredSnapshot:
99- default:
100- close(localRetiredSnapshot)
101- }
102- }
103- }
104- }
105- payload, err := wire.MarshalRouteRetired(protocol.RouteRetired{Ref: protocol.RouteRef{Key: 1, Generation: 1}, Target: protocol.ExactSessionTarget{LifecycleID: localLifecycle, SessionName: "local"}})
106- require.NoError(t, err)
107- localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgRouteRetired, payload)})
108- }
109- localDialer := &sequenceDialer{trs: []wire.Transport{initial, localPicker}}
110- var localDestination *recordingTransport
111- if tc.selectLocal {
112- selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"}
113- localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
114- Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected,
115- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab",
116- }))})
117- localDestination = &recordingTransport{recvs: []recvItem{
118- {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)},
119- {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))},
120- }}
121- localDialer.trs = append(localDialer.trs, localDestination)
122- } else {
123- localPicker.recvs = append(localPicker.recvs, recvItem{f: navigationDirectiveFrame(protocol.NavigationBack), wait: localRetiredSnapshot})
124- }
125- clock := &reconnectTestClock{}
126- deps := hybridPickerDependencies(localDialer, term, clock, map[string]ports.ClientDialer{"igor": remoteDialer})
127- require.NoError(t, runTestClient(t.Context(), deps, client.AttachRequest{
128- Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
129- }))
130-
131- var source protocol.RecentRouteSnapshot
132- for _, sent := range remote.Sends() {
133- if sent.Type == wire.MsgRecentRouteSnapshot {
134- var err error
135- source, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload)
136- require.NoError(t, err)
137- break
138- }
139- }
140- require.Equal(t, "misc", source.ActiveEntry.Name)
141- publications := 0
142- for _, sent := range localPicker.Sends() {
143- if sent.Type != wire.MsgRecentRouteSnapshot {
144- continue
145- }
146- publications++
147- snapshot, err := wire.UnmarshalRecentRouteSnapshot(sent.Payload)
148- require.NoError(t, err)
149- if tc.retireLocal && publications > 1 {
150- require.Empty(t, snapshot.Entries)
151- require.Equal(t, source.Active, snapshot.Active)
152- } else {
153- require.Equal(t, source, snapshot, "rendering the home picker must not commit its backing session as the active route")
154- }
155- }
156- require.Positive(t, publications)
157- if tc.retireLocal {
158- require.Greater(t, publications, 1)
159- returned := remoteReturn
160- if tc.udp {
161- returned = remote
162- }
163- var latest protocol.RecentRouteSnapshot
164- for _, sent := range returned.Sends() {
165- if sent.Type == wire.MsgRecentRouteSnapshot {
166- var err error
167- latest, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload)
168- require.NoError(t, err)
169- }
170- }
171- require.Equal(t, source.ActiveEntry.Target, latest.ActiveEntry.Target)
172- require.Empty(t, latest.Entries, "returning to remote must publish the pruned ledger")
173- }
174- wantDials := int32(2)
175- if tc.udp || tc.selectLocal {
176- wantDials = 1
177- }
178- require.Equal(t, wantDials, remoteDialer.calls.Load())
179- if localDestination != nil {
180- hello := helloFromSend(t, localDestination)
181- require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay)
182- require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID)
183- require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack)
184- var selected protocol.RecentRouteSnapshot
185- for _, sent := range localDestination.Sends() {
186- if sent.Type == wire.MsgRecentRouteSnapshot {
187- var err error
188- selected, err = wire.UnmarshalRecentRouteSnapshot(sent.Payload)
189- require.NoError(t, err)
190- }
191- }
192- require.Equal(t, "destination", selected.ActiveEntry.Name)
193- require.Equal(t, protocol.RouteKindLocal, selected.ActiveEntry.Kind)
194- require.Equal(t, source.Active, selected.Previous)
195- }
196- })
197- }
198-}
A internal/usecase/client/host_registry_internal_test.go +154 -0
@@ -0,0 +1,154 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+// recordingDialer is a typed dialer that never dials: these tests only assert
16+// how a handoff request is derived from the endpoint binding.
17+type recordingDialer struct{ name string }
18+
19+func (d recordingDialer) Dial(context.Context) (ports.ClientConnection, error) {
20+ return nil, errors.New("recording dialer is never dialed")
21+}
22+
23+var _ ports.ClientDialer = recordingDialer{}
24+
25+func TestRunnerHandoffRequestDerivesFromBindingAndTarget(t *testing.T) {
26+ source := domain.RemoteSessionTarget{
27+ Endpoint: "arch", DisplayOrigin: "arch.example", LifecycleID: domain.SessionLifecycleID{2},
28+ SessionName: "work", LiveTabID: "tab-1",
29+ }
30+ target := protocol.AttachTarget{
31+ Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach,
32+ RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
33+ }
34+ binding := ports.RemoteEndpointBinding{Dialer: recordingDialer{name: "arch"}, Environment: []string{"FOO=1"}}
35+ var resolvedEndpoint string
36+ runner := &Runner{
37+ hostRegistry: internalStubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
38+ resolvedEndpoint = endpoint
39+ return binding, nil
40+ }},
41+ }
42+ dialer, request, err := runner.resolveHandoff(context.Background(), target)
43+ require.NoError(t, err)
44+ require.Equal(t, "arch", resolvedEndpoint)
45+ require.Equal(t, binding.Dialer, dialer)
46+ require.Equal(t, AttachRequest{
47+ Intent: protocol.IntentAttach, SessionName: "work", Remote: true,
48+ Environment: []string{"FOO=1"},
49+ Origin: protocol.RouteOriginDiscovery,
50+ OriginKey: "arch",
51+ RemoteTarget: &source,
52+ HostLabel: "arch.example",
53+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
54+ }, request)
55+}
56+
57+func TestRunnerHandoffRequestUsesEndpointLabelWithoutSelection(t *testing.T) {
58+ target := protocol.AttachTarget{
59+ Endpoint: "arch", Session: "work", Intent: protocol.IntentNew, RequestID: 1,
60+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
61+ }
62+ runner := &Runner{
63+ launchedRemote: true,
64+ hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
65+ return ports.RemoteEndpointBinding{Dialer: recordingDialer{}}, nil
66+ }},
67+ }
68+ _, request, err := runner.resolveHandoff(context.Background(), target)
69+ require.NoError(t, err)
70+ require.Equal(t, domain.RemoteDisplayOrigin("arch"), request.HostLabel)
71+ require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
72+}
73+
74+// TestRunnerHandoffEnvironmentPolicy pins the picker environment rule the
75+// composition root used to own: a route chosen from a locally launched client
76+// has no explicit remote selection, so its environment belongs to the daemon.
77+func TestRunnerHandoffEnvironmentPolicy(t *testing.T) {
78+ source := &domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", SessionName: "work"}
79+ tests := []struct {
80+ name string
81+ launchedRemote bool
82+ target protocol.AttachTarget
83+ want protocol.EnvironmentPolicy
84+ }{
85+ {
86+ name: "local picker attach is daemon-owned",
87+ target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
88+ want: protocol.EnvironmentPolicyDaemonOwned,
89+ },
90+ {
91+ name: "local picker create keeps the daemon policy",
92+ target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentNew, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
93+ want: protocol.EnvironmentPolicyDaemonOwned,
94+ },
95+ {
96+ name: "local picker with a remote selection keeps its policy",
97+ target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
98+ want: protocol.EnvironmentPolicyDaemonOwned,
99+ },
100+ {
101+ name: "directly launched remote keeps the client policy",
102+ launchedRemote: true,
103+ target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
104+ want: protocol.EnvironmentPolicyClientOwned,
105+ },
106+ }
107+ for _, tt := range tests {
108+ t.Run(tt.name, func(t *testing.T) {
109+ runner := &Runner{launchedRemote: tt.launchedRemote}
110+ require.Equal(t, tt.want, runner.handoffEnvironmentPolicy(tt.target))
111+ })
112+ }
113+}
114+
115+// TestRunnerValidatesHandoffTargetsBeforeResolving pins that a malformed
116+// daemon-authored target never reaches registry policy: resolution is not even
117+// attempted.
118+func TestRunnerValidatesHandoffTargetsBeforeResolving(t *testing.T) {
119+ mismatched := domain.RemoteSessionTarget{
120+ Endpoint: "other", DisplayOrigin: "other", LifecycleID: domain.SessionLifecycleID{3}, SessionName: "elsewhere",
121+ }
122+ tests := []struct {
123+ name string
124+ target protocol.AttachTarget
125+ }{
126+ {name: "invalid endpoint", target: protocol.AttachTarget{Endpoint: "bad host", Session: "work", Intent: protocol.IntentAttach}},
127+ {name: "invalid session name", target: protocol.AttachTarget{Endpoint: "arch", Session: "bad name", Intent: protocol.IntentAttach}},
128+ {name: "selection identity mismatch", target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: &mismatched, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}},
129+ {name: "selection without daemon-owned environment", target: protocol.AttachTarget{Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach, RemoteTarget: &mismatched, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}},
130+ }
131+ for _, tt := range tests {
132+ t.Run(tt.name, func(t *testing.T) {
133+ resolved := false
134+ runner := &Runner{hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
135+ resolved = true
136+ return ports.RemoteEndpointBinding{Dialer: recordingDialer{}}, nil
137+ }}}
138+ _, _, err := runner.resolveHandoff(context.Background(), tt.target)
139+ require.Error(t, err)
140+ require.False(t, resolved, "an invalid target must be refused before endpoint policy runs")
141+ })
142+ }
143+}
144+
145+func TestRunnerHandoffReportsResolutionFailure(t *testing.T) {
146+ want := errors.New("launch policy refused this endpoint")
147+ runner := &Runner{hostRegistry: internalStubHostRegistry{resolve: func(string) (ports.RemoteEndpointBinding, error) {
148+ return ports.RemoteEndpointBinding{}, want
149+ }}}
150+ _, _, err := runner.resolveHandoff(context.Background(), protocol.AttachTarget{
151+ Endpoint: "arch", Session: "work", Intent: protocol.IntentAttach,
152+ })
153+ require.ErrorIs(t, err, want)
154+}
A internal/usecase/client/hybrid_picker_route_test.go +539 -0
@@ -0,0 +1,539 @@
1+package client_test
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/protocol"
18+ "github.com/bnema/vev/internal/protocol/wire"
19+ "github.com/bnema/vev/internal/usecase/client"
20+)
21+
22+// This file drives the hybrid route-switch journeys through the client-owned
23+// picker: a client attached to a remote serving daemon opens the picker from
24+// the daemon's offer, commits a row, and then follows whichever handoff the
25+// serving daemon authorises. It replaces the deleted parked-route suites,
26+// whose journeys are now the daemon's decision plus the client's same-peer
27+// confirmation or endpoint dial.
28+
29+// hybridPickerTransport is a live wire.Transport the test drives frame by
30+// frame. Recv yields queued frames in order and parks when the queue is
31+// empty, so the journey can react to what the client actually sent.
32+type hybridPickerTransport struct {
33+ mu sync.Mutex
34+ queue []wire.Frame
35+ sends []wire.Frame
36+ signal chan struct{}
37+ done chan struct{}
38+ closeOnce sync.Once
39+ closed atomic.Int32
40+}
41+
42+func newHybridPickerTransport() *hybridPickerTransport {
43+ return &hybridPickerTransport{signal: make(chan struct{}, 1), done: make(chan struct{})}
44+}
45+
46+func (t *hybridPickerTransport) Send(frame wire.Frame) error {
47+ t.mu.Lock()
48+ t.sends = append(t.sends, frame)
49+ t.mu.Unlock()
50+ return nil
51+}
52+
53+func (t *hybridPickerTransport) Recv() (wire.Frame, error) {
54+ for {
55+ t.mu.Lock()
56+ if len(t.queue) > 0 {
57+ frame := t.queue[0]
58+ t.queue = t.queue[1:]
59+ t.mu.Unlock()
60+ return frame, nil
61+ }
62+ t.mu.Unlock()
63+ select {
64+ case <-t.signal:
65+ case <-t.done:
66+ return wire.Frame{}, io.EOF
67+ }
68+ }
69+}
70+
71+func (t *hybridPickerTransport) Close() error {
72+ t.closed.Add(1)
73+ t.closeOnce.Do(func() { close(t.done) })
74+ return nil
75+}
76+
77+func (t *hybridPickerTransport) push(frame wire.Frame) {
78+ t.mu.Lock()
79+ t.queue = append(t.queue, frame)
80+ t.mu.Unlock()
81+ select {
82+ case t.signal <- struct{}{}:
83+ default:
84+ }
85+}
86+
87+func (t *hybridPickerTransport) sent() []wire.Frame {
88+ t.mu.Lock()
89+ defer t.mu.Unlock()
90+ return append([]wire.Frame(nil), t.sends...)
91+}
92+
93+// awaitSend waits for one client frame of the requested type and returns its
94+// payload.
95+func (t *hybridPickerTransport) awaitSend(typ wire.MsgType) ([]byte, bool) {
96+ deadline := time.Now().Add(2 * time.Second)
97+ for time.Now().Before(deadline) {
98+ for _, frame := range t.sent() {
99+ if frame.Type == typ {
100+ return frame.Payload, true
101+ }
102+ }
103+ time.Sleep(time.Millisecond)
104+ }
105+ return nil, false
106+}
107+
108+func (t *hybridPickerTransport) sentType(typ wire.MsgType) bool {
109+ for _, frame := range t.sent() {
110+ if frame.Type == typ {
111+ return true
112+ }
113+ }
114+ return false
115+}
116+
117+// hybridPickerReader yields one carriage return only when the test releases
118+// it, so a keypress can never reach the session before the picker owns input.
119+type hybridPickerReader struct {
120+ mu sync.Mutex
121+ delivered bool
122+ press chan struct{}
123+ done chan struct{}
124+ once sync.Once
125+ closeOnce sync.Once
126+}
127+
128+func newHybridPickerReader() *hybridPickerReader {
129+ return &hybridPickerReader{press: make(chan struct{}), done: make(chan struct{})}
130+}
131+
132+func (r *hybridPickerReader) pressEnter() { r.once.Do(func() { close(r.press) }) }
133+func (r *hybridPickerReader) unblock() { r.closeOnce.Do(func() { close(r.done) }) }
134+
135+func (r *hybridPickerReader) Read(p []byte) (int, error) {
136+ select {
137+ case <-r.press:
138+ r.mu.Lock()
139+ deliver := !r.delivered
140+ r.delivered = true
141+ r.mu.Unlock()
142+ if deliver {
143+ return copy(p, "\r"), nil
144+ }
145+ case <-r.done:
146+ return 0, io.EOF
147+ }
148+ <-r.done
149+ return 0, io.EOF
150+}
151+
152+// hybridPickerTerminal is the headless terminal the runner drives: the picker
153+// frame is written to its buffer and one scripted keypress is its input.
154+type hybridPickerTerminal struct {
155+ reader *hybridPickerReader
156+ mu sync.Mutex
157+ out bytes.Buffer
158+ resize chan domain.Geometry
159+}
160+
161+func newHybridPickerTerminal() *hybridPickerTerminal {
162+ return &hybridPickerTerminal{reader: newHybridPickerReader(), resize: make(chan domain.Geometry)}
163+}
164+
165+func (*hybridPickerTerminal) EnterRaw() (func() error, error) {
166+ return func() error { return nil }, nil
167+}
168+func (*hybridPickerTerminal) Geometry() (domain.Geometry, error) {
169+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
170+}
171+func (t *hybridPickerTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
172+func (t *hybridPickerTerminal) In() io.Reader { return t.reader }
173+func (t *hybridPickerTerminal) Out() io.Writer { return hybridPickerWriter{t} }
174+func (*hybridPickerTerminal) Flush() error { return nil }
175+
176+func (t *hybridPickerTerminal) screen() string {
177+ t.mu.Lock()
178+ defer t.mu.Unlock()
179+ return t.out.String()
180+}
181+
182+type hybridPickerWriter struct{ t *hybridPickerTerminal }
183+
184+func (w hybridPickerWriter) Write(p []byte) (int, error) {
185+ w.t.mu.Lock()
186+ defer w.t.mu.Unlock()
187+ return w.t.out.Write(p)
188+}
189+
190+// awaitDisplay waits until the composed picker frame is on the terminal: the
191+// row labels are rendered verbatim.
192+func (term *hybridPickerTerminal) awaitDisplay(t *testing.T, label string) {
193+ t.Helper()
194+ deadline := time.Now().Add(2 * time.Second)
195+ for time.Now().Before(deadline) {
196+ if bytes.Contains([]byte(term.screen()), []byte(label)) {
197+ return
198+ }
199+ time.Sleep(time.Millisecond)
200+ }
201+ t.Fatalf("the picker frame never showed %q; terminal output was %q", label, term.screen())
202+}
203+
204+func hybridPickerWelcome(name string, lifecycle domain.SessionLifecycleID) wire.Frame {
205+ return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{
206+ SessionID: name + "-id", SessionName: name, ResumeToken: 1,
207+ Capabilities: protocol.CapabilityResume,
208+ CommittedIdentity: &protocol.CommittedRouteIdentity{
209+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
210+ },
211+ }))
212+}
213+
214+// hybridPickerPaint pushes one authoritative full paint, which is both the
215+// output boundary the picker barrier names and the release paint.
216+func hybridPickerPaint(t *testing.T, transport *hybridPickerTransport, epoch uint64, target protocol.ExactSessionTarget, text string) {
217+ t.Helper()
218+ view := protocol.ViewContext{Publication: epoch, Route: protocol.CommittedRouteIdentity{Target: target}, TabID: "t_abc123", FocusedPaneID: "p_def456"}
219+ payload, err := wire.MarshalOutput(protocol.Output{
220+ Epoch: epoch, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view,
221+ Data: []byte("\x1b[2J\x1b[H" + text),
222+ })
223+ require.NoError(t, err)
224+ transport.push(frameOf(wire.MsgOutput, payload))
225+}
226+
227+const hybridPickerInteraction = uint64(7)
228+
229+func hybridPickerOffer() protocol.PickerOffer {
230+ return protocol.PickerOffer{
231+ InteractionID: hybridPickerInteraction, Intent: protocol.PickerIntentNavigation,
232+ Title: " Sessions · recent ", BarrierEpoch: 1, BarrierState: 1, SizeEpoch: 1,
233+ }
234+}
235+
236+func hybridPickerSnapshot() protocol.PickerSnapshot {
237+ return protocol.PickerSnapshot{
238+ InteractionID: hybridPickerInteraction, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK,
239+ Lines: []protocol.PickerLine{
240+ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate},
241+ {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanNavigate},
242+ },
243+ Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
244+ }
245+}
246+
247+// openHybridPicker scripts the serving daemon's offer and first snapshot after
248+// the barrier paint, so the client takes presentation over.
249+func openHybridPicker(t *testing.T, transport *hybridPickerTransport, serving protocol.ExactSessionTarget) {
250+ t.Helper()
251+ hybridPickerPaint(t, transport, 1, serving, "ready")
252+ transport.push(frameOf(wire.MsgPickerOffer, wire.MarshalPickerOffer(hybridPickerOffer())))
253+ transport.push(frameOf(wire.MsgPickerSnapshot, wire.MarshalPickerSnapshot(hybridPickerSnapshot())))
254+}
255+
256+func hybridPickerTargets() (domain.RemoteSessionTarget, protocol.ExactSessionTarget) {
257+ source := domain.RemoteSessionTarget{
258+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{2},
259+ SessionName: "source", LiveTabID: "source-tab",
260+ }
261+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "target"}
262+ return source, target
263+}
264+
265+// TestHybridPickerSameHostSwitchKeepsTheServingTransport pins the hybrid
266+// same-host journey through the client picker: the client commits a row to a
267+// remote serving daemon, the daemon retires the interaction and offers the
268+// same-host target, and the client confirms it on the authenticated
269+// connection instead of dialing a replacement.
270+func TestHybridPickerSameHostSwitchKeepsTheServingTransport(t *testing.T) {
271+ term := newHybridPickerTerminal()
272+ defer term.reader.unblock()
273+
274+ source, target := hybridPickerTargets()
275+ local := newHybridPickerTransport()
276+ remote := newHybridPickerTransport()
277+ handoffEndpoints := make(chan string, 4)
278+
279+ // The bootstrap attaches locally, then hands the client to the remote
280+ // endpoint the serving daemon names.
281+ local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
282+ local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
283+ Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source,
284+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
285+ })))
286+
287+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
288+ localDialer := &sequenceDialer{trs: []wire.Transport{local}}
289+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
290+ deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
291+ handoffEndpoints <- endpoint
292+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
293+ }}
294+
295+ done := make(chan error, 1)
296+ go func() {
297+ done <- runTestClient(context.Background(), deps, client.AttachRequest{
298+ Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
299+ })
300+ }()
301+
302+ remote.push(hybridPickerWelcome("source", source.LifecycleID))
303+ openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"})
304+ term.awaitDisplay(t, "second")
305+ require.Contains(t, term.screen(), "\x1b[?25l", "the client-owned picker must hide the hardware cursor")
306+
307+ // The commit crosses as a typed selection; the daemon then retires the
308+ // interaction and offers the same-host target on the same connection.
309+ term.reader.pressEnter()
310+ selectionPayload, ok := remote.awaitSend(wire.MsgPickerSelection)
311+ require.True(t, ok, "the picker commit never crossed the wire")
312+ selection, err := wire.UnmarshalPickerSelection(selectionPayload)
313+ require.NoError(t, err)
314+ require.Equal(t, hybridPickerInteraction, selection.InteractionID)
315+
316+ remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{
317+ InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1,
318+ })))
319+ remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
320+ Session: target.SessionName, Intent: protocol.IntentAttach, ExactTarget: &target,
321+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, CauseActionID: selection.CauseActionID,
322+ })))
323+
324+ switchPayload, ok := remote.awaitSend(wire.MsgSamePeerSwitchRequest)
325+ require.True(t, ok, "the same-host target must be confirmed on the serving connection")
326+ switchRequest, err := wire.UnmarshalSamePeerSwitchRequest(switchPayload)
327+ require.NoError(t, err)
328+ require.Equal(t, target, switchRequest.Target)
329+
330+ remote.push(frameOf(wire.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(protocol.CommittedRouteIdentity{Target: target})))
331+ hybridPickerPaint(t, remote, 2, target, "target frame")
332+
333+ // The released destination paint is displayed while the client is still on
334+ // the authenticated serving connection: nothing dialed a replacement and
335+ // nothing closed the source to get here.
336+ term.awaitDisplay(t, "target frame")
337+ require.Equal(t, int32(1), remoteDialer.calls.Load(), "a same-host switch must keep the authenticated serving transport")
338+ require.Zero(t, remote.closed.Load(), "a same-host switch must not close the serving transport")
339+
340+ remote.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})))
341+
342+ select {
343+ case err := <-done:
344+ require.NoError(t, err)
345+ case <-time.After(5 * time.Second):
346+ t.Fatal("client did not finish the same-host picker switch")
347+ }
348+ require.Equal(t, []string{"remote"}, drainStrings(handoffEndpoints))
349+ require.False(t, local.sentType(wire.MsgSamePeerSwitchRequest), "the switch belongs to the serving connection")
350+}
351+
352+// TestHybridPickerDifferentHostDialsTheTargetEndpoint pins the cross-host
353+// journey through the client picker: the daemon retires the interaction and
354+// hands the client to another endpoint, which the runner dials through the
355+// composed handoff while the source attachment closes.
356+func TestHybridPickerDifferentHostDialsTheTargetEndpoint(t *testing.T) {
357+ term := newHybridPickerTerminal()
358+ defer term.reader.unblock()
359+
360+ source, _ := hybridPickerTargets()
361+ targetHost := domain.RemoteSessionTarget{
362+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: domain.SessionLifecycleID{4},
363+ SessionName: "target", LiveTabID: "target-tab",
364+ }
365+ local := newHybridPickerTransport()
366+ remote := newHybridPickerTransport()
367+ targetTransport := newHybridPickerTransport()
368+ handoffEndpoints := make(chan string, 4)
369+
370+ local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
371+ local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
372+ Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source,
373+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
374+ })))
375+
376+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote}}
377+ targetDialer := &sequenceDialer{trs: []wire.Transport{targetTransport}}
378+ localDialer := &sequenceDialer{trs: []wire.Transport{local}}
379+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
380+ deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
381+ handoffEndpoints <- endpoint
382+ if endpoint == targetHost.Endpoint {
383+ return ports.RemoteEndpointBinding{Dialer: targetDialer}, nil
384+ }
385+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
386+ }}
387+
388+ done := make(chan error, 1)
389+ go func() {
390+ done <- runTestClient(context.Background(), deps, client.AttachRequest{
391+ Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
392+ })
393+ }()
394+
395+ remote.push(hybridPickerWelcome("source", source.LifecycleID))
396+ openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"})
397+ term.awaitDisplay(t, "second")
398+
399+ term.reader.pressEnter()
400+ selectionPayload, ok := remote.awaitSend(wire.MsgPickerSelection)
401+ require.True(t, ok, "the picker commit never crossed the wire")
402+ selection, err := wire.UnmarshalPickerSelection(selectionPayload)
403+ require.NoError(t, err)
404+
405+ remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{
406+ InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1,
407+ })))
408+ remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
409+ Endpoint: targetHost.Endpoint, Session: targetHost.SessionName, Intent: protocol.IntentAttach,
410+ RemoteTarget: &targetHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
411+ CauseActionID: selection.CauseActionID,
412+ })))
413+
414+ targetTransport.push(hybridPickerWelcome("target", targetHost.LifecycleID))
415+ targetTransport.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})))
416+
417+ select {
418+ case err := <-done:
419+ require.NoError(t, err)
420+ case <-time.After(5 * time.Second):
421+ t.Fatal("client did not finish the cross-host picker handoff")
422+ }
423+ require.Equal(t, []string{"remote", "target-host"}, drainStrings(handoffEndpoints))
424+ require.Equal(t, int32(1), targetDialer.calls.Load(), "the cross-host target must be dialed once")
425+ require.Positive(t, remote.closed.Load(), "the source attachment must close after the cross-host handoff")
426+ require.False(t, remote.sentType(wire.MsgSamePeerSwitchRequest), "a cross-host target is never confirmed on the source connection")
427+ helloPayload, ok := targetTransport.awaitSend(wire.MsgHello)
428+ require.True(t, ok, "the target attachment never sent a Hello")
429+ hello, err := wire.UnmarshalHello(helloPayload)
430+ require.NoError(t, err)
431+ require.Equal(t, "target", hello.Name)
432+ require.NotNil(t, hello.RemoteTarget)
433+ require.Equal(t, targetHost, *hello.RemoteTarget)
434+}
435+
436+func drainStrings(ch chan string) []string {
437+ var out []string
438+ for {
439+ select {
440+ case value := <-ch:
441+ out = append(out, value)
442+ default:
443+ return out
444+ }
445+ }
446+}
447+
448+// TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers pins the registry
449+// invariant the client now owns: a picker route that moves to another serving
450+// peer resolves that endpoint once, and returning to the first peer reuses the
451+// binding it already had instead of rebuilding the carriage.
452+func TestHybridPickerKeepsOneEndpointBindingAcrossServingPeers(t *testing.T) {
453+ term := newHybridPickerTerminal()
454+ defer term.reader.unblock()
455+
456+ source, _ := hybridPickerTargets()
457+ otherHost := domain.RemoteSessionTarget{
458+ Endpoint: "target-host", DisplayOrigin: "target-host", LifecycleID: domain.SessionLifecycleID{4},
459+ SessionName: "other", LiveTabID: "other-tab",
460+ }
461+ local := newHybridPickerTransport()
462+ remote := newHybridPickerTransport()
463+ remoteReturn := newHybridPickerTransport()
464+ other := newHybridPickerTransport()
465+ resolutions := make(chan string, 8)
466+
467+ local.push(hybridPickerWelcome("local", domain.SessionLifecycleID{1}))
468+ local.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
469+ Endpoint: "remote", Session: "source", Intent: protocol.IntentAttach, RemoteTarget: &source,
470+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
471+ })))
472+
473+ remoteDialer := &sequenceDialer{trs: []wire.Transport{remote, remoteReturn}}
474+ otherDialer := &sequenceDialer{trs: []wire.Transport{other}}
475+ localDialer := &sequenceDialer{trs: []wire.Transport{local}}
476+ dialers := map[string]ports.ClientDialer{"remote": remoteDialer, "target-host": otherDialer}
477+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
478+ deps.HostRegistry = stubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
479+ resolutions <- endpoint
480+ dialer := dialers[endpoint]
481+ if dialer == nil {
482+ return ports.RemoteEndpointBinding{}, errors.New("unknown endpoint")
483+ }
484+ return ports.RemoteEndpointBinding{Dialer: dialer}, nil
485+ }}
486+
487+ done := make(chan error, 1)
488+ go func() {
489+ done <- runTestClient(context.Background(), deps, client.AttachRequest{
490+ Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local",
491+ })
492+ }()
493+
494+ remote.push(hybridPickerWelcome("source", source.LifecycleID))
495+ openHybridPicker(t, remote, protocol.ExactSessionTarget{LifecycleID: source.LifecycleID, SessionName: "source"})
496+ term.awaitDisplay(t, "second")
497+
498+ // The serving peer hands the client to another host.
499+ term.reader.pressEnter()
500+ selection, err := wire.UnmarshalPickerSelection(mustAwaitSend(t, remote, wire.MsgPickerSelection))
501+ require.NoError(t, err)
502+ remote.push(frameOf(wire.MsgPickerClosedServer, wire.MarshalPickerClosed(protocol.PickerClosed{
503+ InteractionID: hybridPickerInteraction, BarrierEpoch: 1, BarrierState: 1,
504+ })))
505+ remote.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
506+ Endpoint: otherHost.Endpoint, Session: otherHost.SessionName, Intent: protocol.IntentAttach,
507+ RemoteTarget: &otherHost, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
508+ CauseActionID: selection.CauseActionID,
509+ })))
510+ other.push(hybridPickerWelcome("other", otherHost.LifecycleID))
511+ // The new peer offers the return route to the first host.
512+ other.push(frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{
513+ Endpoint: "remote", Session: source.SessionName, Intent: protocol.IntentAttach,
514+ RemoteTarget: &source, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
515+ })))
516+ remoteReturn.push(hybridPickerWelcome("source", source.LifecycleID))
517+ remoteReturn.push(frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})))
518+
519+ select {
520+ case err := <-done:
521+ require.NoError(t, err)
522+ case <-time.After(5 * time.Second):
523+ t.Fatal("client did not finish the cross-peer picker route")
524+ }
525+ // Returning to the first peer resolves that endpoint again and dials a fresh
526+ // connection through it: the registry owns binding reuse, and a route
527+ // crossing serving peers never invalidates the client's own resolution.
528+ require.Equal(t, int32(2), remoteDialer.calls.Load())
529+ require.Equal(t, int32(1), otherDialer.calls.Load())
530+ require.Equal(t, []string{"remote", "target-host", "remote"}, drainStrings(resolutions))
531+}
532+
533+// mustAwaitSend waits for one client frame of the requested type.
534+func mustAwaitSend(t *testing.T, transport *hybridPickerTransport, typ wire.MsgType) []byte {
535+ t.Helper()
536+ payload, ok := transport.awaitSend(typ)
537+ require.True(t, ok, "frame %d never crossed the wire", typ)
538+ return payload
539+}
M internal/usecase/client/inventory_attempt_test.go +199 -11
@@ -197,6 +197,22 @@ func (t *inventoryAttemptTransport) LinkState() ports.LinkState { return
197 func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil }197 func (t *inventoryAttemptTransport) LinkEvents() <-chan ports.LinkEvent { return nil }
198 func (t *inventoryAttemptTransport) Close() error { return nil }198 func (t *inventoryAttemptTransport) Close() error { return nil }
199 199
200+func (t *inventoryAttemptTransport) latestRouteSnapshot() (protocol.RecentRouteSnapshot, bool) {
201+ t.mu.Lock()
202+ defer t.mu.Unlock()
203+ for i := len(t.sent) - 1; i >= 0; i-- {
204+ if t.sent[i].Type != wire.MsgRecentRouteSnapshot {
205+ continue
206+ }
207+ snapshot, err := wire.UnmarshalRecentRouteSnapshot(t.sent[i].Payload)
208+ if err != nil {
209+ panic(err)
210+ }
211+ return snapshot, true
212+ }
213+ return protocol.RecentRouteSnapshot{}, false
214+}
215+
200 func (t *inventoryAttemptTransport) publicationCount() int {216 func (t *inventoryAttemptTransport) publicationCount() int {
201 t.mu.Lock()217 t.mu.Lock()
202 defer t.mu.Unlock()218 defer t.mu.Unlock()
@@ -221,23 +237,39 @@ func (t *inventoryAttemptTransport) hello() protocol.Hello {
221 // inventoryTestControlConn answers one snapshot or resolve query from canned237 // inventoryTestControlConn answers one snapshot or resolve query from canned
222 // groups without Hello, session creation, or attachment.238 // groups without Hello, session creation, or attachment.
223 type inventoryTestControlConn struct {239 type inventoryTestControlConn struct {
224- request protocol.NavigationInventoryRequest240+ inventoryRequest protocol.NavigationInventoryRequest
241+ pickerRequest protocol.PickerControlRequest
242+ onPicker func()
225 }243 }
226 244
227 func (c *inventoryTestControlConn) SendClient(message protocol.ClientMessage) error {245 func (c *inventoryTestControlConn) SendClient(message protocol.ClientMessage) error {
228- request, ok := message.(protocol.NavigationInventoryRequest)246+ switch request := message.(type) {
229- if !ok {247+ case protocol.NavigationInventoryRequest:
248+ c.inventoryRequest = request
249+ case protocol.PickerControlRequest:
250+ c.pickerRequest = request
251+ if c.onPicker != nil {
252+ c.onPicker()
253+ }
254+ default:
230 return io.ErrUnexpectedEOF255 return io.ErrUnexpectedEOF
231 }256 }
232- c.request = request
233 return nil257 return nil
234 }258 }
235 259
236 func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, error) {260 func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, error) {
237- switch c.request.Operation {261+ if c.pickerRequest.Operation == protocol.PickerControlObserve {
262+ observations := make([]protocol.PickerRouteObservation, len(c.pickerRequest.Targets))
263+ for i, target := range c.pickerRequest.Targets {
264+ observations[i] = protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRoutePresent, Attention: true}
265+ }
266+ return protocol.PickerControlResponse{RequestID: c.pickerRequest.RequestID, Operation: protocol.PickerControlObserve, Status: protocol.PickerSourceOK, Observations: observations}, nil
267+ }
268+ request := c.inventoryRequest
269+ switch request.Operation {
238 case protocol.NavigationInventorySnapshot:270 case protocol.NavigationInventorySnapshot:
239 return protocol.NavigationInventoryResponse{271 return protocol.NavigationInventoryResponse{
240- RequestID: c.request.RequestID, Operation: protocol.NavigationInventorySnapshot,272+ RequestID: request.RequestID, Operation: protocol.NavigationInventorySnapshot,
241 Status: protocol.NavigationInventoryOK,273 Status: protocol.NavigationInventoryOK,
242 Groups: []protocol.NavigationInventorySourceGroup{{274 Groups: []protocol.NavigationInventorySourceGroup{{
243 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,275 SourceKey: "local", Status: protocol.NavigationInventorySourceOK,
@@ -249,7 +281,7 @@ func (c *inventoryTestControlConn) ReceiveServer() (protocol.ServerMessage, erro
249 case protocol.NavigationInventoryResolve:281 case protocol.NavigationInventoryResolve:
250 lifecycle := domain.SessionLifecycleID{7}282 lifecycle := domain.SessionLifecycleID{7}
251 return protocol.NavigationInventoryResponse{283 return protocol.NavigationInventoryResponse{
252- RequestID: c.request.RequestID, Operation: protocol.NavigationInventoryResolve,284+ RequestID: request.RequestID, Operation: protocol.NavigationInventoryResolve,
253 Status: protocol.NavigationInventoryOK,285 Status: protocol.NavigationInventoryOK,
254 Resolved: &protocol.AttachTarget{286 Resolved: &protocol.AttachTarget{
255 Session: "alpha", Intent: protocol.IntentAttach,287 Session: "alpha", Intent: protocol.IntentAttach,
@@ -269,15 +301,28 @@ func (c *inventoryTestControlConn) LinkEvents() <-chan ports.LinkEvent { return
269 func (c *inventoryTestControlConn) Close() error { return nil }301 func (c *inventoryTestControlConn) Close() error { return nil }
270 302
271 type inventoryTestDialer struct {303 type inventoryTestDialer struct {
272- mu sync.Mutex304+ mu sync.Mutex
273- dials int305+ dials int
306+ pickerRequests int
274 }307 }
275 308
276 func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) {309 func (d *inventoryTestDialer) Dial(context.Context) (ports.ClientConnection, error) {
277 d.mu.Lock()310 d.mu.Lock()
278 d.dials++311 d.dials++
279 d.mu.Unlock()312 d.mu.Unlock()
280- return &inventoryTestControlConn{}, nil313+ return &inventoryTestControlConn{onPicker: d.notePickerRequest}, nil
314+}
315+
316+func (d *inventoryTestDialer) notePickerRequest() {
317+ d.mu.Lock()
318+ d.pickerRequests++
319+ d.mu.Unlock()
320+}
321+
322+func (d *inventoryTestDialer) pickerCount() int {
323+ d.mu.Lock()
324+ defer d.mu.Unlock()
325+ return d.pickerRequests
281 }326 }
282 327
283 func (d *inventoryTestDialer) count() int {328 func (d *inventoryTestDialer) count() int {
@@ -291,7 +336,7 @@ func runInventoryAttempt(t *testing.T, clock *inventoryTestClock, transport *inv
291 input := newPaletteAttachReader(nil)336 input := newPaletteAttachReader(nil)
292 t.Cleanup(input.close)337 t.Cleanup(input.close)
293 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}338 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
294- runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}339+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: newRouteLedger()}
295 attempt := &attachAttempt{340 attempt := &attachAttempt{
296 runner: runner, dialer: dialer, transport: transport,341 runner: runner, dialer: dialer, transport: transport,
297 request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"},342 request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"},
@@ -313,6 +358,106 @@ func msForInventoryTest() *milestones { return &milestones{} }
313 // TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery:358 // TestInventoryPollBoxKeepsNewestCompletion pins out-of-order delivery:
314 // a late outcome from an older query must not overwrite the newer one,359 // a late outcome from an older query must not overwrite the newer one,
315 // or the newer completion is lost while its slot stays busy.360 // or the newer completion is lost while its slot stays busy.
361+func TestRemoteAttemptPublishesLocalAttentionObservation(t *testing.T) {
362+ clock := newInventoryTestClock()
363+ dialer := &inventoryTestDialer{}
364+ transport := &inventoryAttemptTransport{
365+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptSilent,
366+ }
367+ t.Cleanup(func() { close(transport.release) })
368+ input := newPaletteAttachReader(nil)
369+ t.Cleanup(input.close)
370+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
371+ ledger := newRouteLedger()
372+ localTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}
373+ _, err := ledger.commitAttach(routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, protocol.CommittedRouteIdentity{Target: localTarget}, dialer, 0))
374+ require.NoError(t, err)
375+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: ledger}
376+ attempt := &attachAttempt{
377+ runner: runner, dialer: dialer, transport: transport, remote: true,
378+ request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work", Origin: protocol.RouteOriginRemote, OriginKey: "remote", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
379+ inventoryHome: &attachRoute{dialer: dialer, request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}},
380+ inventoryDialer: dialer, milestones: msForInventoryTest(), themeState: &terminalThemeState{}, enterRaw: func() error { return nil }, reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
381+ }
382+ done := make(chan attachResult, 1)
383+ go func() { done <- attempt.run(context.Background()) }()
384+
385+ require.Eventually(t, func() bool { return clock.timerCount() >= 2 }, 5*time.Second, 10*time.Millisecond)
386+ require.Eventually(t, func() bool {
387+ clock.fire()
388+ return dialer.pickerCount() > 0
389+ }, 5*time.Second, 10*time.Millisecond, "the observation worker must query the stable authority")
390+ require.Eventually(t, func() bool {
391+ clock.fire()
392+ snapshot, ok := transport.latestRouteSnapshot()
393+ if !ok {
394+ return false
395+ }
396+ for _, entry := range append(snapshot.Entries, snapshot.ActiveEntry) {
397+ if entry.Target == localTarget && entry.Attention {
398+ return true
399+ }
400+ }
401+ return false
402+ }, 5*time.Second, 10*time.Millisecond)
403+ close(transport.detach)
404+ result := <-done
405+ require.NoError(t, result.err)
406+}
407+
408+func TestLocalAttemptPublishesRemoteAttentionObservation(t *testing.T) {
409+ clock := newInventoryTestClock()
410+ localDialer := &inventoryTestDialer{}
411+ remoteDialer := &inventoryTestDialer{}
412+ transport := &inventoryAttemptTransport{
413+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), releaseClose: make(chan struct{}), script: inventoryScriptSilent,
414+ }
415+ t.Cleanup(func() { close(transport.release) })
416+ input := newPaletteAttachReader(nil)
417+ t.Cleanup(input.close)
418+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
419+ ledger := newRouteLedger()
420+ remoteTarget := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"}
421+ remoteRequest := AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginDiscovery, OriginKey: "remote.test", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
422+ _, err := ledger.commitAttach(routeCandidateForAttach(remoteRequest, protocol.CommittedRouteIdentity{Target: remoteTarget}, remoteDialer, 0))
423+ require.NoError(t, err)
424+ observers := newRouteObserverRegistry()
425+ observers.register(remoteRequest, remoteDialer)
426+ require.Len(t, ledger.observationTargets(protocol.RouteOriginDiscovery, "remote.test"), 1)
427+ require.Len(t, observers.snapshot(), 1)
428+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: ledger, routeObservers: observers}
429+ attempt := &attachAttempt{
430+ runner: runner, dialer: localDialer, transport: transport,
431+ request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, OriginKey: "local", EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
432+ milestones: msForInventoryTest(), themeState: &terminalThemeState{}, enterRaw: func() error { return nil }, reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
433+ }
434+ done := make(chan attachResult, 1)
435+ go func() { done <- attempt.run(context.Background()) }()
436+
437+ require.Eventually(t, func() bool { return clock.timerCount() >= 2 }, 5*time.Second, 10*time.Millisecond)
438+ require.Eventually(t, func() bool {
439+ clock.fire()
440+ return remoteDialer.pickerCount() > 0
441+ }, 5*time.Second, 10*time.Millisecond, "the local attempt must query the retained remote authority")
442+ require.Eventually(t, func() bool {
443+ clock.fire()
444+ snapshot, ok := transport.latestRouteSnapshot()
445+ if !ok {
446+ return false
447+ }
448+ for _, entry := range append(snapshot.Entries, snapshot.ActiveEntry) {
449+ if entry.Target == remoteTarget && entry.Attention {
450+ return true
451+ }
452+ }
453+ return false
454+ }, 5*time.Second, 10*time.Millisecond)
455+ close(transport.detach)
456+ result := <-done
457+ require.NoError(t, result.err)
458+ require.Zero(t, localDialer.pickerCount(), "the active local authority has no committed target to observe")
459+}
460+
316 func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) {461 func TestInventoryPollBoxKeepsNewestCompletion(t *testing.T) {
317 box := newInventoryPollBox()462 box := newInventoryPollBox()
318 box.offer(inventoryPollOutcome{interaction: 2, query: 2})463 box.offer(inventoryPollOutcome{interaction: 2, query: 2})
@@ -436,3 +581,46 @@ func TestInventoryAttemptIgnoresDemandsWhenLocal(t *testing.T) {
436 require.Zero(t, dialer.count())581 require.Zero(t, dialer.count())
437 require.Zero(t, transport.publicationCount())582 require.Zero(t, transport.publicationCount())
438 }583 }
584+
585+// TestInventoryAttemptDirectRemoteNeverDialsControl pins the direct-remote
586+// pairing: a serving daemon that sends inventory demands against an
587+// attachment with no committed home route must not trigger a local control
588+// dial. The relay is hybrid-only; without a home route no relay exists, so
589+// demands decode to no-ops and the attachment lifecycle is unaffected.
590+func TestInventoryAttemptDirectRemoteNeverDialsControl(t *testing.T) {
591+ clock := newInventoryTestClock()
592+ dialer := &inventoryTestDialer{}
593+ transport := &inventoryAttemptTransport{
594+ published: make(chan struct{}), release: make(chan struct{}), detach: make(chan struct{}), script: inventoryScriptLocalIgnore,
595+ }
596+ t.Cleanup(func() { close(transport.release) })
597+
598+ input := newPaletteAttachReader(nil)
599+ t.Cleanup(input.close)
600+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Geometry)}
601+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
602+ attempt := &attachAttempt{
603+ runner: runner, dialer: dialer, transport: transport,
604+ request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote-work"},
605+ remote: true,
606+ // No inventoryHome: a direct-remote start has no committed home
607+ // route, so no relay may exist even when demands arrive.
608+ inventoryDialer: dialer,
609+ milestones: msForInventoryTest(), themeState: &terminalThemeState{},
610+ enterRaw: func() error { return nil },
611+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
612+ }
613+
614+ done := make(chan attachResult, 1)
615+ go func() { done <- attempt.run(context.Background()) }()
616+ close(transport.detach)
617+
618+ result := <-done
619+ require.NoError(t, result.err)
620+ require.Nil(t, result.handoff)
621+ require.Zero(t, dialer.count(), "direct remote without a home route must never dial local control")
622+ require.Zero(t, transport.publicationCount())
623+ hello := transport.hello()
624+ require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityInventory,
625+ "direct remote must not advertise inventory without a home route")
626+}
M internal/usecase/client/inventory_recovery_test.go +1 -3
@@ -76,9 +76,7 @@ func TestInventoryFailureRestoresLatestRemoteIdentityAfterResumeRejection(t *tes
76 }).Twice()76 }).Twice()
77 deps := testDependencies(localDialer, term, realClock{}, nil, nil)77 deps := testDependencies(localDialer, term, realClock{}, nil, nil)
78 deps.LocalControlDialer = control78 deps.LocalControlDialer = control
79- deps.AttachHandoff = func(protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) {79+ deps.HostRegistry = dialerForEndpoints(map[string]ports.ClientDialer{"remote": remoteDialer})
80- return remoteDialer, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: first.SessionName, Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "remote", HostLabel: "remote", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, nil
81- }
82 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"}))80 require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: local.SessionName, Origin: protocol.RouteOriginLocal, OriginKey: "local"}))
83 for _, tr := range []*recordingTransport{resume, restored} {81 for _, tr := range []*recordingTransport{resume, restored} {
84 hello := helloFromSend(t, tr)82 hello := helloFromSend(t, tr)
M internal/usecase/client/navigation_inventory.go +7 -1
@@ -164,7 +164,13 @@ func (r *inventoryRelay) preparePublication(groups []protocol.NavigationInventor
164 if r == nil {164 if r == nil {
165 return nil, 0, false165 return nil, 0, false
166 }166 }
167- canonical := canonicalInventoryGroups(groups)167+ localGroups := make([]protocol.NavigationInventorySourceGroup, 0, 1)
168+ for _, group := range groups {
169+ if group.SourceKey == protocol.NavigationInventoryLocalSourceKey {
170+ localGroups = append(localGroups, group)
171+ }
172+ }
173+ canonical := canonicalInventoryGroups(localGroups)
168 if inventoryGroupsEqual(canonical, r.published) {174 if inventoryGroupsEqual(canonical, r.published) {
169 return nil, 0, false175 return nil, 0, false
170 }176 }
M internal/usecase/client/navigation_inventory_test.go +18 -0
@@ -7,6 +7,7 @@ import (
7 7
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
10+ "github.com/stretchr/testify/require"
10 )11 )
11 12
12 // manualClock is a fake ports.Clock with explicit time control: no sleeps.13 // manualClock is a fake ports.Clock with explicit time control: no sleeps.
@@ -178,6 +179,23 @@ func TestInventoryRelaySnapshotAdmitsAndSuppressesUnchanged(t *testing.T) {
178 }179 }
179 }180 }
180 181
182+func TestInventoryRelayPublishesOnlyLocalSource(t *testing.T) {
183+ relay := newInventoryRelay(&manualClock{now: time.Unix(1_000, 0)}, &inventoryFakeDialer{})
184+ relay.setOpen(true, 5)
185+ groups := append(inventoryTestGroups(), protocol.NavigationInventorySourceGroup{
186+ SourceKey: "remote", Status: protocol.NavigationInventorySourceOK,
187+ Entries: []protocol.NavigationInventoryEntry{{SourceKey: "remote", EntryKey: "ticker", Name: "ticker"}},
188+ })
189+
190+ published, _, changed := relay.preparePublication(groups)
191+ require.True(t, changed)
192+ require.Equal(t, inventoryTestGroups(), published)
193+
194+ groups[1].Entries[0].Name = "changed-remote"
195+ _, _, changed = relay.preparePublication(groups)
196+ require.False(t, changed, "unsupported remote inventory must not alter local publication")
197+}
198+
181 func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) {199 func TestInventoryRelaySelectionSurvivesRefreshLag(t *testing.T) {
182 clock := &manualClock{now: time.Unix(1_000, 0)}200 clock := &manualClock{now: time.Unix(1_000, 0)}
183 groups := inventoryTestGroups()201 groups := inventoryTestGroups()
M internal/usecase/client/navigation_test.go +3 -12
@@ -27,24 +27,15 @@ func TestSyncReconnectRemoteKeepsRequestAndToastClassificationTogether(t *testin
27 func TestValidateAttachRequestNavigationTable(t *testing.T) {27 func TestValidateAttachRequestNavigationTable(t *testing.T) {
28 var lifecycle domain.SessionLifecycleID28 var lifecycle domain.SessionLifecycleID
29 lifecycle[0] = 129 lifecycle[0] = 1
30- remoteRoute := &domain.RemoteSessionTarget{
31- Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
32- SessionName: "work", LiveTabID: "tab-1",
33- }
34 tests := []struct {30 tests := []struct {
35 name string31 name string
36 request AttachRequest32 request AttachRequest
37 valid bool33 valid bool
38 }{34 }{
39 {name: "ordinary route", request: AttachRequest{}, valid: true},35 {name: "ordinary route", request: AttachRequest{}, valid: true},
40- {name: "home picker without remote route", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},36+ {name: "inventory capability", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", NavigationCapabilities: protocol.NavigationCapabilityInventory}, valid: true},
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},37+ {name: "ephemeral inventory capability", request: AttachRequest{NavigationCapabilities: protocol.NavigationCapabilityInventory}},
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},38+ {name: "unknown capability", request: AttachRequest{NavigationCapabilities: 16}, valid: false},
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},
48 }39 }
49 40
50 for _, tt := range tests {41 for _, tt := range tests {
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -129,7 +129,7 @@ func newAttachPaletteTransport() *attachPaletteTransport {
129 return &attachPaletteTransport{129 return &attachPaletteTransport{
130 themeCh: make(chan protocol.Theme, 16),130 themeCh: make(chan protocol.Theme, 16),
131 inputCh: make(chan protocol.Input, 16),131 inputCh: make(chan protocol.Input, 16),
132- detached: make(chan wire.Frame, 1),132+ detached: make(chan wire.Frame, 16),
133 }133 }
134 }134 }
135 func (t *attachPaletteTransport) Send(frame wire.Frame) error {135 func (t *attachPaletteTransport) Send(frame wire.Frame) error {
A internal/usecase/client/picker_control.go +87 -0
@@ -0,0 +1,87 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+type pickerControl struct{ dialer ports.ClientDialer }
14+
15+func (c pickerControl) request(ctx context.Context, request protocol.PickerControlRequest) (protocol.PickerControlResponse, error) {
16+ if c.dialer == nil {
17+ return protocol.PickerControlResponse{}, errors.New("vev: picker authority unavailable")
18+ }
19+ ctx, cancel := context.WithTimeout(ctx, inventoryQueryTimeout)
20+ defer cancel()
21+ conn, err := c.dialer.Dial(ctx)
22+ if err != nil {
23+ return protocol.PickerControlResponse{}, err
24+ }
25+ var once sync.Once
26+ closeConn := func() { once.Do(func() { _ = conn.Close() }) }
27+ defer closeConn()
28+ done := make(chan struct{})
29+ go func() {
30+ select {
31+ case <-ctx.Done():
32+ closeConn()
33+ case <-done:
34+ }
35+ }()
36+ defer close(done)
37+ if err := conn.SendClient(request); err != nil {
38+ return protocol.PickerControlResponse{}, err
39+ }
40+ message, err := conn.ReceiveServer()
41+ if err != nil {
42+ return protocol.PickerControlResponse{}, err
43+ }
44+ response, ok := message.(protocol.PickerControlResponse)
45+ if !ok || protocol.ValidatePickerControlResponse(response) != nil || response.RequestID != request.RequestID || response.Operation != request.Operation {
46+ return protocol.PickerControlResponse{}, errors.New("vev: invalid picker authority response")
47+ }
48+ return response, nil
49+}
50+
51+func (c pickerControl) snapshot(ctx context.Context, requestID, interaction uint64) (protocol.PickerSnapshot, error) {
52+ response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlSnapshot})
53+ if err != nil || response.Status != protocol.PickerSourceOK || response.Snapshot == nil {
54+ return protocol.PickerSnapshot{}, errors.New("vev: picker authority snapshot unavailable")
55+ }
56+ snapshot := *response.Snapshot
57+ snapshot.InteractionID = interaction
58+ return snapshot, nil
59+}
60+
61+func (c pickerControl) observe(ctx context.Context, requestID uint64, targets []protocol.ExactSessionTarget) ([]protocol.PickerRouteObservation, error) {
62+ response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlObserve, Targets: targets})
63+ if err != nil {
64+ return nil, fmt.Errorf("vev: picker authority observation unavailable: %w", err)
65+ }
66+ if response.Status != protocol.PickerSourceOK || len(response.Observations) != len(targets) {
67+ return nil, errors.New("vev: picker authority observation unavailable")
68+ }
69+ requested := make(map[protocol.ExactSessionTarget]struct{}, len(targets))
70+ for _, target := range targets {
71+ requested[target] = struct{}{}
72+ }
73+ for _, observation := range response.Observations {
74+ if _, ok := requested[observation.Target]; !ok {
75+ return nil, errors.New("vev: picker authority returned an unexpected observation target")
76+ }
77+ }
78+ return response.Observations, nil
79+}
80+
81+func (c pickerControl) resolve(ctx context.Context, requestID uint64, selection protocol.PickerSelection) (protocol.AttachTarget, error) {
82+ response, err := c.request(ctx, protocol.PickerControlRequest{Version: protocol.Version, RequestID: requestID, Operation: protocol.PickerControlResolve, SourceRevision: selection.SourceRevision, SourceID: selection.SourceID, Key: selection.Key})
83+ if err != nil || response.Status != protocol.PickerSourceOK || response.Resolved == nil {
84+ return protocol.AttachTarget{}, errors.New("vev: picker authority rejected selection")
85+ }
86+ return *response.Resolved, nil
87+}
A internal/usecase/client/picker_control_box.go +47 -0
@@ -0,0 +1,47 @@
1+package client
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+type pickerControlOutcome struct {
10+ snapshot *protocol.PickerSnapshot
11+ selection *protocol.PickerSelection
12+ target *protocol.AttachTarget
13+ err error
14+}
15+
16+type pickerControlBox struct {
17+ mu sync.Mutex
18+ outcomes []pickerControlOutcome
19+ wake chan struct{}
20+}
21+
22+func newPickerControlBox() *pickerControlBox { return &pickerControlBox{wake: make(chan struct{}, 1)} }
23+func (b *pickerControlBox) offer(outcome pickerControlOutcome) {
24+ b.mu.Lock()
25+ b.outcomes = append(b.outcomes, outcome)
26+ b.mu.Unlock()
27+ select {
28+ case b.wake <- struct{}{}:
29+ default:
30+ }
31+}
32+func (b *pickerControlBox) take() (pickerControlOutcome, bool) {
33+ b.mu.Lock()
34+ defer b.mu.Unlock()
35+ if len(b.outcomes) == 0 {
36+ return pickerControlOutcome{}, false
37+ }
38+ outcome := b.outcomes[0]
39+ b.outcomes = b.outcomes[1:]
40+ if len(b.outcomes) != 0 {
41+ select {
42+ case b.wake <- struct{}{}:
43+ default:
44+ }
45+ }
46+ return outcome, true
47+}
A internal/usecase/client/picker_e2e_support_test.go +335 -0
@@ -0,0 +1,335 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/adapters/uiterm"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+// internalStubHostRegistry is the internal tests' host registry: it resolves
21+// endpoints from a table and parks its discovery loop until the runner exits.
22+type internalStubHostRegistry struct {
23+ resolve func(endpoint string) (ports.RemoteEndpointBinding, error)
24+}
25+
26+func (r internalStubHostRegistry) ResolveEndpoint(_ context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
27+ if r.resolve == nil {
28+ return ports.RemoteEndpointBinding{}, errors.New("stub host registry has no resolver")
29+ }
30+ return r.resolve(endpoint)
31+}
32+func (internalStubHostRegistry) Snapshot() ports.RemoteDirectorySnapshot {
33+ return ports.RemoteDirectorySnapshot{}
34+}
35+func (internalStubHostRegistry) Subscribe() ports.RemoteDirectorySubscription { return nil }
36+func (internalStubHostRegistry) RequestReconcile(string) {}
37+func (internalStubHostRegistry) RegistryChanged() {}
38+func (internalStubHostRegistry) Run(ctx context.Context) error {
39+ <-ctx.Done()
40+ return nil
41+}
42+
43+// newPickerTestTerminal builds the headless terminal backing both the UI
44+// state and the test's snapshot observations.
45+func newPickerTestTerminal(t *testing.T, ctx context.Context) (*uiterm.Terminal, *uiterm.Terminal) {
46+ t.Helper()
47+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
48+ require.NoError(t, err)
49+ t.Cleanup(terminal.Close)
50+ return terminal, terminal
51+}
52+
53+// waitForText waits for the UI to publish a frame containing the fragment
54+// after the given action committed.
55+func waitForText(t *testing.T, ctx context.Context, ui *UI, afterAction uint64, fragment string) {
56+ t.Helper()
57+ attached := ports.UIStatusAttached
58+ matched, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), AfterAction: afterAction, Expect: ports.UIExpect{TextContains: &fragment, Status: &attached}})
59+ require.NoError(t, err)
60+ require.NotZero(t, matched.Revision)
61+}
62+
63+// pickerE2EHarness is one running attach attempt driven through the real
64+// wire transport, with the first full frame already committed so automation
65+// batches admit and the foreground owns the terminal.
66+type pickerE2EHarness struct {
67+ attempt *attachAttempt
68+ transport *attachPaletteTransport
69+ ui *UI
70+ terminal *uiterm.Terminal
71+ input *terminalInputPump
72+ clock *attachPaletteClock
73+}
74+
75+// startPickerE2E boots the attach attempt and pushes the first full frame.
76+func startPickerE2E(t *testing.T) *pickerE2EHarness {
77+ return startPickerE2EHarness(t, nil, true)
78+}
79+
80+// startPickerE2EWithInput boots the same harness over a caller-owned
81+// terminal reader, so tests can drive physical input instead of the
82+// ui-driver automation channel.
83+func startPickerE2EWithInput(t *testing.T, reader io.Reader) *pickerE2EHarness {
84+ return startPickerE2EHarness(t, reader, true)
85+}
86+
87+// startPickerE2EHeadless boots the harness without the UI-driver
88+// composition (runner.ui is nil), which is how the ordinary CLI runs. The
89+// picker must own and complete physical input there too.
90+func startPickerE2EHeadless(t *testing.T, reader io.Reader) *pickerE2EHarness {
91+ return startPickerE2EHarness(t, reader, false)
92+}
93+
94+func startPickerE2EHarness(t *testing.T, reader io.Reader, withUI bool) *pickerE2EHarness {
95+ t.Helper()
96+ ctx, cancel := context.WithCancel(context.Background())
97+ t.Cleanup(cancel)
98+ clock := newAttachPaletteClock()
99+ transport := newAttachPaletteTransport()
100+ ms := &milestones{}
101+ state := &terminalThemeState{}
102+ input := newTerminalInputPump(reader)
103+ if reader != nil {
104+ // The harness owns this reader: start its loop so physical bytes
105+ // reach the attach attempt. The nil case keeps the automation-only
106+ // path used by the ui-driver scenarios.
107+ input.start()
108+ }
109+ t.Cleanup(input.stop)
110+ uiTerminal, _ := newPickerTestTerminal(t, ctx)
111+ var ui *UI
112+ runner := &Runner{term: uiTerminal, clock: clock, logger: slog.New(slog.DiscardHandler), ledger: newRouteLedger()}
113+ if withUI {
114+ ui = NewUI(uiTerminal, systemClock{})
115+ runner.ui = ui
116+ }
117+ source := routeTestCandidate(0, protocol.RouteOriginLocal)
118+ source.target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}
119+ source.presentation.name = "fixture"
120+ source.request.SessionName = "fixture"
121+ _, err := runner.ledger.commit(source)
122+ require.NoError(t, err)
123+ attempt := &attachAttempt{
124+ runner: runner, transport: transport, request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "fixture"},
125+ milestones: ms, themeState: state,
126+ enterRaw: func() error { ms.rawEntered = true; return nil },
127+ reconnect: &reconnectUI{term: uiTerminal, rawEntered: new(bool)},
128+ terminalInput: func() *terminalInputPump {
129+ return input
130+ },
131+ }
132+ done := make(chan attachResult, 1)
133+ go func() { done <- attempt.run(context.Background()) }()
134+ t.Cleanup(func() {
135+ transport.detached <- wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach})}
136+ select {
137+ case result := <-done:
138+ require.NoError(t, result.err)
139+ case <-time.After(2 * time.Second):
140+ t.Error("attempt did not detach")
141+ }
142+ })
143+ handshakeTimer := <-clock.handshakeTimers
144+ select {
145+ case <-handshakeTimer.stopped:
146+ // Welcome and synchronous client publications commit the handshake.
147+ case <-time.After(2 * time.Second):
148+ t.Fatal("handshake deadline still owned the runtime")
149+ }
150+
151+ // The synthetic Welcome carries no Output frame, so push one full frame
152+ // through the wire: it starts the foreground, publishes the UI boundary,
153+ // and lets automation batches admit.
154+ view0 := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "t_abc123", FocusedPaneID: "p_def456"}
155+ output0, err := wire.MarshalOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view0, Data: []byte("\x1b[Hready")})
156+ require.NoError(t, err)
157+ transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: output0}
158+
159+ return &pickerE2EHarness{attempt: attempt, transport: transport, ui: ui, terminal: uiTerminal, input: input, clock: clock}
160+}
161+
162+// awaitTerminalText waits until the terminal shows the fragment. It is the
163+// headless replacement for UI.Wait: the picker frame is written to the same
164+// terminal, so the drawn text is the barrier.
165+func awaitTerminalText(t *testing.T, terminal *uiterm.Terminal, fragment string) {
166+ t.Helper()
167+ deadline := time.After(2 * time.Second)
168+ for {
169+ snapshot, err := terminal.Snapshot()
170+ if err == nil && strings.Contains(uiSnapshotText(snapshot), fragment) {
171+ return
172+ }
173+ select {
174+ case <-deadline:
175+ t.Fatalf("terminal never showed %q", fragment)
176+ case <-terminal.Changes():
177+ case <-time.After(5 * time.Millisecond):
178+ }
179+ }
180+}
181+
182+// frameCount reports how many client frames crossed the wire so far.
183+func (t *attachPaletteTransport) frameCount() int {
184+ t.mu.Lock()
185+ defer t.mu.Unlock()
186+ return len(t.frames)
187+}
188+
189+// awaitAfterAmbiguityDeadlines fires the 20 ms ambiguity timers the pump
190+// arms until the wanted frame crosses the wire. The palette-marker scanner
191+// and the picker decoder share that deadline, so an escape withheld as a
192+// possible marker is flushed into the picker decoder before its own window
193+// starts. More than a few deadlines for one escape means the window is
194+// being re-armed instead of expiring, so the helper fails instead of
195+// firing forever.
196+func (h *pickerE2EHarness) awaitAfterAmbiguityDeadlines(t *testing.T, transport *attachPaletteTransport, frameType wire.MsgType) {
197+ t.Helper()
198+ const maxDeadlines = 4
199+ hasFrame := func() bool {
200+ transport.mu.Lock()
201+ defer transport.mu.Unlock()
202+ for _, frame := range transport.frames {
203+ if frame.Type == frameType {
204+ return true
205+ }
206+ }
207+ return false
208+ }
209+ deadline := time.After(3 * time.Second)
210+ fired := 0
211+ for {
212+ select {
213+ case timer := <-h.clock.timers:
214+ if timer.duration == pickerEscapeDeadline {
215+ timer.fire()
216+ fired++
217+ if fired > maxDeadlines {
218+ t.Fatalf("the escape deadline was re-armed %d times for one prefix", fired)
219+ }
220+ }
221+ default:
222+ }
223+ if hasFrame() {
224+ return
225+ }
226+ select {
227+ case <-deadline:
228+ t.Fatalf("frame %d never crossed the wire", frameType)
229+ case <-time.After(time.Millisecond):
230+ }
231+ }
232+}
233+
234+// pickerOffer opens the interaction both scenarios drive.
235+func pickerOffer() protocol.PickerOffer {
236+ return protocol.PickerOffer{
237+ InteractionID: 7, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent ",
238+ BarrierEpoch: 1, BarrierState: 1, SizeEpoch: 1,
239+ }
240+}
241+
242+// pickerSnapshot returns the two-row source publication both scenarios open
243+// the picker with.
244+func pickerSnapshot() protocol.PickerSnapshot {
245+ return protocol.PickerSnapshot{
246+ InteractionID: 7, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK,
247+ Lines: []protocol.PickerLine{
248+ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate},
249+ {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanNavigate},
250+ },
251+ Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
252+ }
253+}
254+
255+// openPickerOnHarness publishes the offer and first snapshot the serving daemon
256+// would send, so the client takes over presentation.
257+func openPickerOnHarness(t *testing.T, transport *attachPaletteTransport) protocol.PickerSnapshot {
258+ t.Helper()
259+ offer := pickerOffer()
260+ transport.detached <- wire.Frame{Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)}
261+ snapshot := pickerSnapshot()
262+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)}
263+ return snapshot
264+}
265+
266+// awaitPreviewTimer returns the first armed preview debounce. Other timers
267+// (palette deadlines, escape windows) are left untouched for their own helpers.
268+func (h *pickerE2EHarness) awaitPreviewTimer(t *testing.T) *attachPaletteTimer {
269+ t.Helper()
270+ deadline := time.After(2 * time.Second)
271+ var held []*attachPaletteTimer
272+ defer func() {
273+ for _, timer := range held {
274+ h.clock.timers <- timer
275+ }
276+ }()
277+ for {
278+ select {
279+ case timer := <-h.clock.timers:
280+ if timer.duration != pickerPreviewDebounce {
281+ held = append(held, timer)
282+ continue
283+ }
284+ return timer
285+ case <-deadline:
286+ t.Fatal("the picker preview debounce was never armed")
287+ return nil
288+ }
289+ }
290+}
291+
292+// awaitWireFrame waits for one client frame of the requested type.
293+func awaitWireFrame(t *testing.T, transport *attachPaletteTransport, frameType wire.MsgType) []byte {
294+ t.Helper()
295+ deadline := time.After(2 * time.Second)
296+ for {
297+ select {
298+ case <-deadline:
299+ t.Fatalf("frame %d never crossed the wire", frameType)
300+ return nil
301+ default:
302+ transport.mu.Lock()
303+ var payload []byte
304+ for _, frame := range transport.frames {
305+ if frame.Type == frameType {
306+ payload = frame.Payload
307+ break
308+ }
309+ }
310+ transport.mu.Unlock()
311+ if payload != nil {
312+ return payload
313+ }
314+ time.Sleep(time.Millisecond)
315+ }
316+ }
317+}
318+
319+// awaitPickerSelection waits for the typed commit on the wire transport.
320+func awaitPickerSelection(t *testing.T, transport *attachPaletteTransport) protocol.PickerSelection {
321+ t.Helper()
322+ selection, err := wire.UnmarshalPickerSelection(awaitWireFrame(t, transport, wire.MsgPickerSelection))
323+ require.NoError(t, err)
324+ return selection
325+}
326+
327+// requireNoPickerInput fails when any MsgInput frame reached the PTY.
328+func requireNoPickerInput(t *testing.T, transport *attachPaletteTransport) {
329+ t.Helper()
330+ select {
331+ case frame := <-transport.inputCh:
332+ t.Fatalf("picker bytes leaked to the PTY: %q", frame.Data)
333+ case <-time.After(200 * time.Millisecond):
334+ }
335+}
A internal/usecase/client/picker_e2e_test.go +313 -0
@@ -0,0 +1,313 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ renderer "github.com/bnema/vev-vt"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+ "github.com/bnema/vev/internal/usecase/picker"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+// TestClientPickerLoopEndToEnd drives the full client-picker path through
19+// the real attach attempt with the palette wire transport: Welcome, then a
20+// PickerSnapshot opens the loop and installs the pump-side hook, a Down op
21+// repaints locally and completes without daemon evidence, Enter queues a
22+// typed PickerSelection on the wire, and the daemon-side PickerClose +
23+// same-peer handoff resolve the session. Zero picker bytes reach the PTY:
24+// no MsgInput frame carries picker data.
25+func TestClientPickerLoopEndToEnd(t *testing.T) {
26+ ctx := context.Background()
27+ harness := startPickerE2E(t)
28+ ui, transport := harness.ui, harness.transport
29+
30+ // Daemon opens the picker: two rows, cursor on the first.
31+ snapshot := openPickerOnHarness(t, transport)
32+ attached := ports.UIStatusAttached
33+ pickerText := "first"
34+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &pickerText}})
35+ require.NoError(t, err)
36+
37+ // Cursor op completes locally once the repaint flushes: no daemon
38+ // evidence needed, and the frame carries the second row.
39+ downDone := make(chan struct{})
40+ var downResult ports.UIActionResult
41+ var downErr error
42+ go func() {
43+ defer close(downDone)
44+ downResult, downErr = ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Down"}})
45+ }()
46+ <-downDone
47+ require.NoError(t, downErr)
48+ require.Equal(t, ports.UIActionProcessed, downResult.Status)
49+ waitForText(t, ctx, ui, downResult.ActionID, "second")
50+
51+ // Commit op stays pending: the typed selection crosses the wire with
52+ // the action as cause, and zero picker bytes reach the PTY.
53+ committed := make(chan ports.UIActionResult, 1)
54+ commitErr := make(chan error, 1)
55+ go func() {
56+ action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}})
57+ if err != nil {
58+ commitErr <- err
59+ return
60+ }
61+ committed <- action
62+ }()
63+ selection := awaitPickerSelection(t, transport)
64+ require.Equal(t, uint64(7), selection.InteractionID)
65+ require.Equal(t, "serving", selection.SourceID)
66+ require.Equal(t, uint64(1), selection.SourceRevision)
67+ require.Equal(t, protocol.PickerActionNavigate, selection.Action)
68+ require.Equal(t, "bb/second", selection.Key)
69+ require.NotZero(t, selection.CauseActionID)
70+ requireNoPickerInput(t, transport)
71+
72+ // Daemon resolves: the close confirmation ends the client interaction
73+ // exactly like a resolved selection, then the same-peer handoff and full
74+ // paint commit the session.
75+ second := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "second"}
76+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})}
77+ transport.detached <- wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(protocol.AttachTarget{Session: second.SessionName, Intent: protocol.IntentAttach, ExactTarget: &second, SamePeer: true, CauseActionID: selection.CauseActionID})}
78+ identityPayload, err := wire.MarshalCommittedRouteIdentity(protocol.CommittedRouteIdentity{Target: second})
79+ require.NoError(t, err)
80+ transport.detached <- wire.Frame{Type: wire.MsgCommittedRouteIdentity, Payload: identityPayload}
81+ view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: second}, TabID: "t_abc123", FocusedPaneID: "p_def456"}
82+ outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hsecond")})
83+ require.NoError(t, err)
84+ transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload}
85+ select {
86+ case action := <-committed:
87+ require.Equal(t, ports.UIActionProcessed, action.Status)
88+ require.Equal(t, "second", action.Context.Route.Target.SessionName)
89+ case err := <-commitErr:
90+ t.Fatalf("commit failed after daemon resolve: %v", err)
91+ case <-time.After(2 * time.Second):
92+ t.Fatal("commit never completed after daemon resolve")
93+ }
94+ requireNoPickerInput(t, transport)
95+}
96+
97+// TestClientPickerCompositeMoveCompletesFromResult drives the ui-driver action
98+// through the composite move+follow sequence: the daemon closes the
99+// interaction, publishes the destination route and its full paint on the same
100+// connection, then reports the correlated PickerResult. The action must
101+// complete as processed without a same-peer handoff, exactly like an in-place
102+// mutation.
103+func TestClientPickerCompositeMoveCompletesFromResult(t *testing.T) {
104+ ctx := context.Background()
105+ harness := startPickerE2E(t)
106+ ui, transport := harness.ui, harness.transport
107+
108+ offer := pickerOffer()
109+ offer.Intent = protocol.PickerIntentMoveTab
110+ offer.Title = " Move "
111+ transport.detached <- wire.Frame{Type: wire.MsgPickerOffer, Payload: wire.MarshalPickerOffer(offer)}
112+ snapshot := pickerSnapshot()
113+ for i := range snapshot.Lines {
114+ snapshot.Lines[i].Actions = protocol.PickerCanMove
115+ }
116+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(snapshot)}
117+ attached := ports.UIStatusAttached
118+ first := "first"
119+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
120+ require.NoError(t, err)
121+
122+ committed := make(chan ports.UIActionResult, 1)
123+ commitErr := make(chan error, 1)
124+ go func() {
125+ action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}})
126+ if err != nil {
127+ commitErr <- err
128+ return
129+ }
130+ committed <- action
131+ }()
132+ selection := awaitPickerSelection(t, transport)
133+ require.Equal(t, protocol.PickerActionMove, selection.Action)
134+ require.NotZero(t, selection.CauseActionID)
135+ requireNoPickerInput(t, transport)
136+
137+ // The daemon retires the interaction, then publishes the destination route
138+ // and full paint without a same-peer attach offer.
139+ second := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "second"}
140+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})}
141+ identityPayload, err := wire.MarshalCommittedRouteIdentity(protocol.CommittedRouteIdentity{Target: second})
142+ require.NoError(t, err)
143+ transport.detached <- wire.Frame{Type: wire.MsgCommittedRouteIdentity, Payload: identityPayload}
144+ view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: second}, TabID: "t_abc123", FocusedPaneID: "p_def456"}
145+ outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hsecond")})
146+ require.NoError(t, err)
147+ transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload}
148+ // Completion rides the fresh effect the move postcommit admitted on the
149+ // published destination capability.
150+ resultPayload := wire.MarshalPickerResult(protocol.PickerResult{
151+ CauseActionID: selection.CauseActionID, InteractionID: snapshot.InteractionID,
152+ SourceID: "serving", Key: selection.Key, Action: protocol.PickerActionMove,
153+ })
154+ transport.detached <- wire.Frame{Type: wire.MsgPickerResult, Payload: resultPayload}
155+
156+ select {
157+ case action := <-committed:
158+ require.Equal(t, ports.UIActionProcessed, action.Status)
159+ require.Equal(t, "second", action.Context.Route.Target.SessionName)
160+ case err := <-commitErr:
161+ t.Fatalf("composite move failed: %v", err)
162+ case <-time.After(2 * time.Second):
163+ t.Fatal("move action never completed from the correlated PickerResult")
164+ }
165+ requireNoPickerInput(t, transport)
166+}
167+
168+// TestClientPickerRequestsAndRendersTheDisplayedRowPreview drives the preview
169+// path end to end: the debounce settles on the displayed row, the request names
170+// it, the daemon's viewport is drawn in the modal, and a late answer for a row
171+// the user already left leaves the displayed viewport untouched.
172+func TestClientPickerRequestsAndRendersTheDisplayedRowPreview(t *testing.T) {
173+ ctx := context.Background()
174+ harness := startPickerE2E(t)
175+ ui, transport := harness.ui, harness.transport
176+
177+ snapshot := openPickerOnHarness(t, transport)
178+ attached := ports.UIStatusAttached
179+ first := "first"
180+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
181+ require.NoError(t, err)
182+
183+ harness.awaitPreviewTimer(t).fire()
184+ request, err := wire.UnmarshalPickerPreviewRequest(awaitWireFrame(t, transport, wire.MsgPickerPreviewRequest))
185+ require.NoError(t, err)
186+ require.Equal(t, snapshot.InteractionID, request.InteractionID)
187+ require.Equal(t, "serving", request.SourceID)
188+ require.Equal(t, "aa/first", request.Key)
189+ // The client asks for the preview pane it will draw into, not the whole
190+ // terminal: both sides resolve the same modal geometry.
191+ preview := picker.PreviewRect(domain.Size{Cols: 80, Rows: 24})
192+ require.Equal(t, uint16(preview.Width), request.Width)
193+ require.Equal(t, uint16(preview.Height), request.Height)
194+
195+ viewport := protocol.PickerPreview{
196+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: snapshot.InteractionID, SourceID: "serving",
197+ Key: "aa/first", Status: protocol.PickerPreviewOK, Width: 4, Height: 1,
198+ Cells: []renderer.Cell{{Rune: 'v'}, {Rune: 'i'}, {Rune: 'e'}, {Rune: 'w'}},
199+ }
200+ transport.detached <- wire.Frame{Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(viewport)}
201+ awaitTerminalText(t, harness.terminal, "view")
202+
203+ // A late viewport for a row the user left must not replace the displayed one.
204+ late := viewport
205+ late.Key = "bb/second"
206+ late.Cells = []renderer.Cell{{Rune: 's'}, {Rune: 't'}, {Rune: 'a'}, {Rune: 'l'}}
207+ transport.detached <- wire.Frame{Type: wire.MsgPickerPreview, Payload: wire.MarshalPickerPreview(late)}
208+ awaitTerminalText(t, harness.terminal, "view")
209+ require.NotContains(t, uiSnapshotText(mustCapture(t, ui)), "stal")
210+}
211+
212+func mustCapture(t *testing.T, ui *UI) ports.UISnapshot {
213+ t.Helper()
214+ snapshot, err := ui.Capture(ui.Handle())
215+ require.NoError(t, err)
216+ return snapshot
217+}
218+
219+// TestClientPickerCancelReleasesOnAuthoritativeFullPaint drives the cancel
220+// path through the same real attempt: an idle Escape retires the
221+// interaction locally and types the close on the wire, the picker frame
222+// stays and the action stays pending, and only the daemon's authoritative
223+// full paint releases the terminal and completes the cancel.
224+func TestClientPickerCancelReleasesOnAuthoritativeFullPaint(t *testing.T) {
225+ ctx := context.Background()
226+ harness := startPickerE2E(t)
227+ ui, transport := harness.ui, harness.transport
228+
229+ snapshot := openPickerOnHarness(t, transport)
230+ attached := ports.UIStatusAttached
231+ pickerText := "first"
232+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &pickerText}})
233+ require.NoError(t, err)
234+ requireNoPickerInput(t, transport)
235+
236+ cancelled := make(chan ports.UIActionResult, 1)
237+ cancelErr := make(chan error, 1)
238+ go func() {
239+ action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}})
240+ if err != nil {
241+ cancelErr <- err
242+ return
243+ }
244+ cancelled <- action
245+ }()
246+ closePayload := awaitWireFrame(t, transport, wire.MsgPickerCloseClient)
247+ closeMessage, err := wire.UnmarshalPickerClose(closePayload)
248+ require.NoError(t, err)
249+ require.Equal(t, snapshot.InteractionID, closeMessage.InteractionID)
250+ requireNoPickerInput(t, transport)
251+
252+ // The close alone releases nothing: the action is still pending and the
253+ // picker frame still owns the screen.
254+ select {
255+ case action := <-cancelled:
256+ t.Fatalf("cancel completed before the release paint: %+v", action)
257+ case <-time.After(100 * time.Millisecond):
258+ }
259+ snapshotState, err := ui.Capture(ui.Handle())
260+ require.NoError(t, err)
261+ require.Contains(t, uiSnapshotText(snapshotState), "second")
262+
263+ // The daemon confirms and repaints authoritatively: the frame is
264+ // displayed and the pending cancel completes against that boundary.
265+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})}
266+ view := protocol.ViewContext{Publication: 2, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "t_abc123", FocusedPaneID: "p_def456"}
267+ outputPayload, err := wire.MarshalOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &view, Data: []byte("\x1b[2J\x1b[Hreleased")})
268+ require.NoError(t, err)
269+ transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: outputPayload}
270+ select {
271+ case action := <-cancelled:
272+ require.Equal(t, ports.UIActionProcessed, action.Status)
273+ case err := <-cancelErr:
274+ t.Fatalf("cancel failed after the release paint: %v", err)
275+ case <-time.After(2 * time.Second):
276+ t.Fatal("cancel never completed after the release paint")
277+ }
278+ after, err := ui.Capture(ui.Handle())
279+ require.NoError(t, err)
280+ require.NotContains(t, uiSnapshotText(after), "second")
281+ require.Contains(t, uiSnapshotText(after), "released")
282+}
283+
284+// TestClientPickerDrawsAFloatingModalOverTheSession pins the presentation the
285+// picker must keep: a centred bordered box over the session, not a full-screen
286+// takeover. The session text painted before the picker opened stays visible
287+// around the box, and only the box's own cells are written.
288+func TestClientPickerDrawsAFloatingModalOverTheSession(t *testing.T) {
289+ ctx := context.Background()
290+ harness := startPickerE2E(t)
291+ ui, transport := harness.ui, harness.transport
292+ attached := ports.UIStatusAttached
293+ ready := "ready"
294+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &ready}})
295+ require.NoError(t, err, "the session owns the screen before the picker opens")
296+
297+ openPickerOnHarness(t, transport)
298+ first := "first"
299+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
300+ require.NoError(t, err)
301+
302+ text := uiSnapshotText(mustCapture(t, ui))
303+ require.Contains(t, text, "first", "the picker rows must be drawn")
304+ require.Contains(t, text, "ready", "the session must stay visible around the floating box")
305+ require.Contains(t, text, "─", "the modal border must be drawn")
306+
307+ // The box is the shared picker modal, not the whole terminal.
308+ size := domain.Size{Cols: 80, Rows: 24}
309+ presentation := picker.Modal.Resolve(size)
310+ require.Less(t, presentation.Bounds.Width, size.Cols)
311+ require.Less(t, presentation.Bounds.Height, size.Rows)
312+ require.Contains(t, text, picker.Modal.Title, "the picker box keeps its title")
313+}
A internal/usecase/client/picker_input.go +358 -0
@@ -0,0 +1,358 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+ "unicode/utf8"
8+)
9+
10+// This file owns user-input routing for a client-owned picker interaction.
11+//
12+// The attach loop is the single owner of the picker model: it publishes who
13+// owns user input through pickerConsumer, and the stdin pump only decodes
14+// batches into identified operations. The pump never mutates the model, so
15+// the two goroutines never share it.
16+//
17+// While an interaction owns input, every byte read from the terminal
18+// belongs to the picker: unknown or oversized sequences are consumed and
19+// dropped rather than forwarded to the session. There is deliberately no
20+// fallback to the PTY pipeline.
21+
22+// pickerPendingLimit bounds a withheld escape-sequence prefix. A longer
23+// incomplete sequence is unrecognized input and is discarded.
24+const pickerPendingLimit = 8
25+
26+// pickerEscapeDeadline bounds how long a lone ESC is withheld while the
27+// picker owns input, so ESC and the start of a CSI/SS3 sequence are told
28+// apart without ever falling back to the session pipeline.
29+const pickerEscapeDeadline = 20 * time.Millisecond
30+
31+// Bracketed-paste delimiters. Their content is dropped: a paste must never
32+// become a run of modal commands, and the picker has no text field that
33+// wants pasted input.
34+const (
35+ pasteOpenMarker = "\x1b[200~"
36+ pasteCloseMarker = "\x1b[201~"
37+)
38+
39+// pickerInputState is the attach loop's publication of the current input
40+// owner: zero interaction with drain unset means the session owns input.
41+type pickerInputState struct {
42+ interaction uint64
43+ generation uint64
44+ // drain consumes and drops every batch while the interaction is
45+ // retired but its authoritative repaint is still in flight.
46+ drain bool
47+}
48+
49+// pickerEventKind distinguishes a command token from a typed rune. Events
50+// keep their arrival order: a batch must never reorder input.
51+type pickerEventKind uint8
52+
53+const (
54+ pickerEventKey pickerEventKind = iota
55+ pickerEventRune
56+)
57+
58+// pickerEvent is one decoded input event.
59+type pickerEvent struct {
60+ kind pickerEventKind
61+ key string
62+ r rune
63+}
64+
65+// pickerConsumer is the slot shared between the attach loop and the stdin
66+// pump. It protects the published owner, the withheld escape or UTF-8
67+// prefix, and the paste state; it holds no picker model.
68+type pickerConsumer struct {
69+ mu sync.Mutex
70+ state pickerInputState
71+ pending []byte
72+ pasting bool
73+ pasteTail []byte
74+}
75+
76+// setOwned publishes the interaction that now owns user input.
77+func (c *pickerConsumer) setOwned(interaction, generation uint64) {
78+ if c == nil {
79+ return
80+ }
81+ c.mu.Lock()
82+ c.state = pickerInputState{interaction: interaction, generation: generation}
83+ c.resetDecoderLocked()
84+ c.mu.Unlock()
85+}
86+
87+// setDrain publishes the release window: the interaction is retired, its
88+// repaint is in flight, and every batch must be consumed and dropped so a
89+// picker-bound key can never reach the session.
90+func (c *pickerConsumer) setDrain(interaction, generation uint64) {
91+ if c == nil {
92+ return
93+ }
94+ c.mu.Lock()
95+ c.state = pickerInputState{interaction: interaction, generation: generation, drain: true}
96+ c.resetDecoderLocked()
97+ c.mu.Unlock()
98+}
99+
100+// clear releases input back to the session and purges the decoder state.
101+func (c *pickerConsumer) clear() {
102+ if c == nil {
103+ return
104+ }
105+ c.mu.Lock()
106+ c.state = pickerInputState{}
107+ c.resetDecoderLocked()
108+ c.mu.Unlock()
109+}
110+
111+// resetDecoderLocked drops a withheld prefix and any paste state. A retired
112+// owner must never leak a half-decoded sequence into the next one.
113+func (c *pickerConsumer) resetDecoderLocked() {
114+ c.pending = nil
115+ c.pasting = false
116+ c.pasteTail = nil
117+}
118+
119+// consume decodes one read or admitted automation batch into an identified
120+// operation. The second result is false when the session owns input, so the
121+// caller keeps its normal pipeline. During the release window every batch is
122+// consumed and dropped.
123+func (c *pickerConsumer) consume(record terminalReadResult) (pickerConsumeOutcome, bool) {
124+ if c == nil {
125+ return pickerConsumeOutcome{}, false
126+ }
127+ c.mu.Lock()
128+ state := c.state
129+ if state.interaction == 0 && !state.drain {
130+ c.mu.Unlock()
131+ return pickerConsumeOutcome{}, false
132+ }
133+ outcome := pickerConsumeOutcome{
134+ consumed: true, actionID: record.actionID,
135+ generation: state.generation, interaction: state.interaction,
136+ }
137+ switch {
138+ case state.drain:
139+ // Retired interaction: consume and drop, including any prefix.
140+ c.resetDecoderLocked()
141+ case record.keys == nil && record.text == "":
142+ var batch pickerInputBatch
143+ c.pending, c.pasting, c.pasteTail = c.decodeLocked(record.data, &batch)
144+ outcome.events = batch.events
145+ default:
146+ outcome.events = automationEvents(record.keys, record.text)
147+ }
148+ c.mu.Unlock()
149+ return outcome, true
150+}
151+
152+// flushPending resolves a withheld prefix once its disambiguation window
153+// expired. A bare escape becomes Escape; an incomplete escape sequence is
154+// dropped; an incomplete UTF-8 prefix is dropped. A paste in progress stays
155+// in progress: its content is never replayed as commands.
156+func (c *pickerConsumer) flushPending() (pickerConsumeOutcome, bool) {
157+ if c == nil {
158+ return pickerConsumeOutcome{}, false
159+ }
160+ c.mu.Lock()
161+ state, pending := c.state, c.pending
162+ c.pending = nil
163+ c.mu.Unlock()
164+ if len(pending) == 0 || state.drain {
165+ return pickerConsumeOutcome{}, false
166+ }
167+ outcome := pickerConsumeOutcome{consumed: true, generation: state.generation, interaction: state.interaction}
168+ if string(pending) == "\x1b" {
169+ outcome.events = []pickerEvent{{kind: pickerEventKey, key: "Escape"}}
170+ }
171+ return outcome, true
172+}
173+
174+// hasPending reports whether an escape or UTF-8 prefix is withheld.
175+func (c *pickerConsumer) hasPending() bool {
176+ if c == nil {
177+ return false
178+ }
179+ c.mu.Lock()
180+ defer c.mu.Unlock()
181+ return len(c.pending) != 0
182+}
183+
184+// automationEvents converts one admitted ui-driver op into ordered events.
185+func automationEvents(keys []string, text string) []pickerEvent {
186+ events := make([]pickerEvent, 0, len(keys)+len(text))
187+ for _, key := range keys {
188+ events = append(events, pickerEvent{kind: pickerEventKey, key: key})
189+ }
190+ for _, r := range text {
191+ events = append(events, pickerEvent{kind: pickerEventRune, r: r})
192+ }
193+ return events
194+}
195+
196+// decoder carries the paste state across reads: a paste's boundaries can
197+// span several terminal reads, and its content must never be interpreted.
198+type decoder struct {
199+ pending []byte
200+ pasting bool
201+ tail []byte
202+}
203+
204+// decodeLocked decodes bytes under the consumer lock and returns the next
205+// pending prefix, paste state, and paste tail.
206+func (c *pickerConsumer) decodeLocked(data []byte, batch *pickerInputBatch) ([]byte, bool, []byte) {
207+ d := decoder{pending: c.pending, pasting: c.pasting, tail: c.pasteTail}
208+ d.decode(data, batch)
209+ return d.pending, d.pasting, d.tail
210+}
211+
212+// decode consumes one read into ordered events. A non-nil pending result is
213+// an incomplete escape or UTF-8 prefix to withhold until the next read.
214+func (d *decoder) decode(data []byte, batch *pickerInputBatch) {
215+ buf := data
216+ if len(d.pending) != 0 {
217+ buf = append(append([]byte(nil), d.pending...), data...)
218+ d.pending = nil
219+ }
220+ if d.pasting {
221+ buf = append(append([]byte(nil), d.tail...), buf...)
222+ d.tail = nil
223+ }
224+ for i := 0; i < len(buf); {
225+ if d.pasting {
226+ end := bytes.Index(buf[i:], []byte(pasteCloseMarker))
227+ if end < 0 {
228+ // Hold only enough trailing bytes to recognize a marker split
229+ // across reads; everything else in the paste is dropped.
230+ keep := len(pasteCloseMarker) - 1
231+ if len(buf)-i > keep {
232+ d.tail = append([]byte(nil), buf[len(buf)-keep:]...)
233+ } else {
234+ d.tail = append([]byte(nil), buf[i:]...)
235+ }
236+ return
237+ }
238+ i += end + len(pasteCloseMarker)
239+ d.pasting = false
240+ continue
241+ }
242+ switch b := buf[i]; {
243+ case b == '\r' || b == '\n':
244+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Enter"})
245+ i++
246+ case b == 0x7f || b == 0x08:
247+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Backspace"})
248+ i++
249+ case b == 0x03:
250+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Ctrl+C"})
251+ i++
252+ case b == 0x1b:
253+ consumed, held, paste := decodeEscape(buf[i:], batch)
254+ if held != nil {
255+ d.pending = held
256+ return
257+ }
258+ if paste {
259+ d.pasting = true
260+ }
261+ i += consumed
262+ case b < 0x20:
263+ // Other control bytes carry no picker meaning; consume them so
264+ // they cannot reach the session.
265+ i++
266+ case b < utf8.RuneSelf:
267+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: string(rune(b))})
268+ i++
269+ default:
270+ r, size := utf8.DecodeRune(buf[i:])
271+ if r == utf8.RuneError && size <= 1 {
272+ if !utf8PrefixIncomplete(buf[i:]) {
273+ i++ // Invalid UTF-8: consume the byte and keep scanning.
274+ continue
275+ }
276+ d.pending = append([]byte(nil), buf[i:]...)
277+ return
278+ }
279+ batch.events = append(batch.events, pickerEvent{kind: pickerEventRune, r: r})
280+ i += size
281+ }
282+ }
283+}
284+
285+// decodeEscape consumes one escape-led sequence starting at buf[0]. It
286+// returns the number of bytes consumed; a non-nil result is an incomplete
287+// prefix to withhold until the next read. paste reports the bracketed-paste
288+// start marker.
289+func decodeEscape(buf []byte, batch *pickerInputBatch) (consumed int, held []byte, paste bool) {
290+ if len(buf) == 1 {
291+ return 0, append([]byte(nil), buf...), false
292+ }
293+ if buf[1] != '[' && buf[1] != 'O' {
294+ // ESC followed by an ordinary byte: the escape stands alone.
295+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Escape"})
296+ return 1, nil, false
297+ }
298+ if len(buf) == 2 {
299+ return 0, append([]byte(nil), buf...), false
300+ }
301+ switch buf[2] {
302+ case 'A':
303+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Up"})
304+ return 3, nil, false
305+ case 'B':
306+ batch.events = append(batch.events, pickerEvent{kind: pickerEventKey, key: "Down"})
307+ return 3, nil, false
308+ }
309+ // Any other CSI/SS3 sequence (mouse reports, probes, paste markers) is
310+ // consumed through its final byte and dropped.
311+ final := -1
312+ for i := 2; i < len(buf); i++ {
313+ if buf[i] >= 0x40 && buf[i] <= 0x7e {
314+ final = i
315+ break
316+ }
317+ }
318+ if final < 0 {
319+ if len(buf) > pickerPendingLimit {
320+ return len(buf), nil, false
321+ }
322+ return 0, append([]byte(nil), buf...), false
323+ }
324+ seq := buf[:final+1]
325+ if string(seq) == pasteOpenMarker {
326+ return final + 1, nil, true
327+ }
328+ return final + 1, nil, false
329+}
330+
331+// utf8PrefixIncomplete reports whether b is a valid but incomplete UTF-8
332+// sequence start, which must be withheld until its continuation bytes
333+// arrive instead of being dropped.
334+func utf8PrefixIncomplete(b []byte) bool {
335+ if len(b) == 0 || !utf8.RuneStart(b[0]) {
336+ return false
337+ }
338+ need := 0
339+ switch {
340+ case b[0]&0xe0 == 0xc0:
341+ need = 2
342+ case b[0]&0xf0 == 0xe0:
343+ need = 3
344+ case b[0]&0xf8 == 0xf0:
345+ need = 4
346+ default:
347+ return false
348+ }
349+ if len(b) >= need {
350+ return false
351+ }
352+ for _, c := range b[1:] {
353+ if c&0xc0 != 0x80 {
354+ return false
355+ }
356+ }
357+ return true
358+}
A internal/usecase/client/picker_input_test.go +336 -0
@@ -0,0 +1,336 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/usecase/picker"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// keyEvents builds one key batch for applyPickerBatch.
18+func keyEvents(keys ...string) []pickerEvent {
19+ events := make([]pickerEvent, 0, len(keys))
20+ for _, key := range keys {
21+ events = append(events, pickerEvent{kind: pickerEventKey, key: key})
22+ }
23+ return events
24+}
25+
26+// TestPickerPhysicalInputDecoding pins the byte-level decoder: recognized
27+// keys, fragmented sequences, fragmented UTF-8, paste state, and the
28+// deliberate consumption of everything the picker does not understand.
29+// Nothing is left for the session pipeline.
30+func TestPickerPhysicalInputDecoding(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ pending []byte
34+ tail string
35+ pasting bool
36+ data []byte
37+ wantKeys []string
38+ wantText string
39+ wantPending string
40+ wantTail string
41+ wantPasting bool
42+ }{
43+ {name: "up arrow csi", data: []byte("\x1b[A"), wantKeys: []string{"Up"}},
44+ {name: "up arrow ss3", data: []byte("\x1bOA"), wantKeys: []string{"Up"}},
45+ {name: "down arrow csi", data: []byte("\x1b[B"), wantKeys: []string{"Down"}},
46+ {name: "lone escape is withheld", data: []byte("\x1b"), wantPending: "\x1b"},
47+ {name: "csi prefix is withheld", data: []byte("\x1b["), wantPending: "\x1b["},
48+ {name: "fragmented arrow", pending: []byte("\x1b["), data: []byte("A"), wantKeys: []string{"Up"}},
49+ {name: "enter", data: []byte("\r"), wantKeys: []string{"Enter"}},
50+ {name: "backspace", data: []byte("\x7f"), wantKeys: []string{"Backspace"}},
51+ {name: "ctrl-c", data: []byte("\x03"), wantKeys: []string{"Ctrl+C"}},
52+ {name: "printable ascii", data: []byte("jkq"), wantKeys: []string{"j", "k", "q"}},
53+ {name: "utf8 rune is text", data: []byte("é"), wantText: "é"},
54+ {name: "fragmented utf8 first byte", data: []byte{0xc3}, wantPending: string([]byte{0xc3})},
55+ {name: "fragmented utf8 completed", pending: []byte{0xc3}, data: []byte{0xa9}, wantText: "é"},
56+ {name: "ordered ascii then rune", data: []byte("aé"), wantKeys: []string{"a"}, wantText: "é"},
57+ {name: "mouse report is consumed", data: []byte("\x1b[<0;5;5M")},
58+ {name: "unknown csi is consumed", data: []byte("\x1b[Z")},
59+ {name: "oversized incomplete sequence is dropped", data: []byte("\x1b[1234567890"), wantPending: ""},
60+ {name: "escape before ordinary byte", data: []byte("\x1bq"), wantKeys: []string{"Escape", "q"}},
61+ // A paste is a state, not a heuristic: the content between the
62+ // markers is consumed even when it arrives across reads, and even
63+ // when it spells a command.
64+ {name: "paste start alone", data: []byte("\x1b[200~"), wantPasting: true},
65+ {name: "paste content and end", pasting: true, data: []byte("q\x1b[201~"), wantPasting: false},
66+ // Only the marker-relevant suffix of a paste is retained: the rest is
67+ // dropped, and a split end marker still resolves on the next read.
68+ {name: "paste content split across reads", pasting: true, data: []byte("hidden\x1b["), wantPasting: true, wantTail: "den\x1b["},
69+ {name: "paste end split across reads", pasting: true, tail: "\x1b[", data: []byte("201~"), wantPasting: false},
70+ }
71+ for _, test := range tests {
72+ t.Run(test.name, func(t *testing.T) {
73+ var batch pickerInputBatch
74+ d := decoder{pending: test.pending, pasting: test.pasting, tail: []byte(test.tail)}
75+ d.decode(test.data, &batch)
76+ var keys []string
77+ var text string
78+ for _, event := range batch.events {
79+ if event.kind == pickerEventKey {
80+ keys = append(keys, event.key)
81+ continue
82+ }
83+ text += string(event.r)
84+ }
85+ require.Equal(t, test.wantKeys, keys)
86+ require.Equal(t, test.wantText, text)
87+ require.Equal(t, test.wantPending, string(d.pending))
88+ require.Equal(t, test.wantTail, string(d.tail))
89+ require.Equal(t, test.wantPasting, d.pasting)
90+ })
91+ }
92+}
93+
94+// TestPickerInputConsumerOwnership pins who owns terminal input and what
95+// the release window does with it: consume and drop, never replay.
96+func TestPickerInputConsumerOwnership(t *testing.T) {
97+ var consumer pickerConsumer
98+ _, consumed := consumer.consume(terminalReadResult{data: []byte("x")})
99+ require.False(t, consumed, "the session owns input while no interaction is open")
100+
101+ consumer.setOwned(7, 3)
102+ outcome, consumed := consumer.consume(terminalReadResult{data: []byte("\x1b[B")})
103+ require.True(t, consumed)
104+ require.Equal(t, uint64(7), outcome.interaction)
105+ require.Equal(t, uint64(3), outcome.generation)
106+ require.Equal(t, keyEvents("Down"), outcome.events)
107+
108+ // The release window consumes and drops, and purges any withheld prefix.
109+ consumer.setDrain(7, 3)
110+ outcome, consumed = consumer.consume(terminalReadResult{data: []byte("y")})
111+ require.True(t, consumed)
112+ require.Empty(t, outcome.events)
113+
114+ // Clearing hands input back to the session.
115+ consumer.clear()
116+ _, consumed = consumer.consume(terminalReadResult{data: []byte("z")})
117+ require.False(t, consumed)
118+}
119+
120+// TestPickerApplyBatchAppliesEveryEvent pins that a terminal read carrying
121+// several legitimate keystrokes is applied in order: batch size never
122+// decides whether input is a command. Paste protection is the decoder's
123+// paste state, which is covered by the decoding table.
124+func TestPickerApplyBatchAppliesEveryEvent(t *testing.T) {
125+ open := func() *pickerLoop {
126+ return pickerLoopFromSnapshot(pickerSnapshot(), protocol.PickerIntentNavigation, picker.SortRecent)
127+ }
128+ selectedKey := func(t *testing.T, loop *pickerLoop) string {
129+ t.Helper()
130+ selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)
131+ require.True(t, ok)
132+ return selection.Key
133+ }
134+
135+ loop := open()
136+ op, changed := applyPickerBatch(loop, keyEvents("Down"))
137+ require.False(t, op.commit)
138+ require.False(t, op.close)
139+ require.True(t, changed, "a cursor move repaints")
140+
141+ // Two keystrokes in one read both act, in order.
142+ loop = open()
143+ _, changed = applyPickerBatch(loop, keyEvents("Down", "Down"))
144+ require.True(t, changed)
145+ require.Equal(t, "bb/second", selectedKey(t, loop), "the second Down is clamped at the last row")
146+ loop.up()
147+ require.Equal(t, "aa/first", selectedKey(t, loop), "both Downs moved the cursor")
148+
149+ loop = open()
150+ op, _ = applyPickerBatch(loop, keyEvents("Escape"))
151+ require.True(t, op.close)
152+
153+ // A close inside a batch ends it: events after it do not act.
154+ loop = open()
155+ op, _ = applyPickerBatch(loop, keyEvents("Escape", "Down"))
156+ require.True(t, op.close)
157+
158+ // Search inserts runes and printable keys in arrival order.
159+ loop = open()
160+ loop.enterSearch()
161+ _, changed = applyPickerBatch(loop, []pickerEvent{
162+ {kind: pickerEventRune, r: 'é'},
163+ {kind: pickerEventKey, key: "a"},
164+ })
165+ require.True(t, changed)
166+ op, _ = applyPickerBatch(loop, keyEvents("Escape"))
167+ require.False(t, op.close, "Escape leaves search first")
168+ require.False(t, loop.model.SearchActive())
169+ op, _ = applyPickerBatch(loop, keyEvents("Escape"))
170+ require.True(t, op.close, "a second Escape closes the picker")
171+}
172+
173+// TestPickerOutcomeScope pins that a decoded operation only applies to the
174+// interaction and attachment generation that admitted it: this is what makes
175+// a generation change (reconnect) and a superseded interaction safe without
176+// forwarding their input anywhere.
177+func TestPickerOutcomeScope(t *testing.T) {
178+ outcome := pickerConsumeOutcome{consumed: true, interaction: 7, generation: 3}
179+ require.True(t, outcome.acceptOutcome(7, 3))
180+ require.False(t, outcome.acceptOutcome(7, 4), "a new generation must not apply the old operation")
181+ require.False(t, outcome.acceptOutcome(8, 3), "a superseding interaction must not apply it either")
182+ require.False(t, pickerConsumeOutcome{interaction: 7, generation: 3}.acceptOutcome(7, 3), "an unconsumed outcome never applies")
183+}
184+
185+// TestPickerPhysicalInputWithoutUIDriver pins that the picker owns and
186+// completes physical input in the ordinary CLI composition, where the
187+// ui-driver (and its automation channel) does not exist: the picker frame
188+// is observed on the terminal itself, then real bytes drive it.
189+func TestPickerPhysicalInputWithoutUIDriver(t *testing.T) {
190+ reader, writer := io.Pipe()
191+ t.Cleanup(func() { _ = writer.Close() })
192+ harness := startPickerE2EHeadless(t, reader)
193+ require.Nil(t, harness.ui, "this harness has no ui-driver")
194+ transport := harness.transport
195+
196+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())}
197+ // The drawn picker is the barrier: write only once it owns the screen.
198+ awaitTerminalText(t, harness.terminal, "second")
199+
200+ // Down then Enter commits the row the physical keys selected.
201+ writeTerminal(t, writer, "\x1b[B")
202+ writeTerminal(t, writer, "\r")
203+ selection := awaitPickerSelection(t, transport)
204+ require.Equal(t, "bb/second", selection.Key)
205+ requireNoPickerInput(t, transport)
206+}
207+
208+// TestPickerPhysicalInputOwnsSession drives real terminal bytes through the
209+// pump: the picker reacts by committing the row the physical keys selected,
210+// the close crosses the wire, and no byte ever reaches the PTY pipeline.
211+func TestPickerPhysicalInputOwnsSession(t *testing.T) {
212+ ctx := context.Background()
213+ reader, writer := io.Pipe()
214+ t.Cleanup(func() { _ = writer.Close() })
215+ harness := startPickerE2EWithInput(t, reader)
216+ ui, transport := harness.ui, harness.transport
217+
218+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())}
219+ attached := ports.UIStatusAttached
220+ first := "first"
221+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
222+ require.NoError(t, err)
223+
224+ // A physical Down arrow then Enter commits the row the cursor reached:
225+ // both rows are on screen from the start, so the committed key is the
226+ // only proof the movement happened.
227+ writeTerminal(t, writer, "\x1b[B")
228+ writeTerminal(t, writer, "\r")
229+ selection := awaitPickerSelection(t, transport)
230+ require.Equal(t, "bb/second", selection.Key)
231+ requireNoPickerInput(t, transport)
232+
233+ // A physical Escape closes the picker once the disambiguation window
234+ // expires: the client sends the typed close and never leaks the byte.
235+ writeTerminal(t, writer, "\x1b")
236+ harness.awaitAfterAmbiguityDeadlines(t, transport, wire.MsgPickerCloseClient)
237+ requireNoPickerInput(t, transport)
238+}
239+
240+// TestPickerPhysicalPasteNeverActs drives a bracketed paste through the pump
241+// in fragments: its markers and content are consumed, and the command it
242+// spells never fires.
243+func TestPickerPhysicalPasteNeverActs(t *testing.T) {
244+ ctx := context.Background()
245+ reader, writer := io.Pipe()
246+ t.Cleanup(func() { _ = writer.Close() })
247+ harness := startPickerE2EWithInput(t, reader)
248+ ui, transport := harness.ui, harness.transport
249+
250+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())}
251+ attached := ports.UIStatusAttached
252+ first := "first"
253+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
254+ require.NoError(t, err)
255+
256+ before := transport.frameCount()
257+ writeTerminal(t, writer, "\x1b[200~")
258+ writeTerminal(t, writer, "q\r")
259+ writeTerminal(t, writer, "\x1b[201~")
260+ time.Sleep(200 * time.Millisecond)
261+ require.Equal(t, before, transport.frameCount(), "paste produced a client frame")
262+ requireNoPickerInput(t, transport)
263+
264+ // The picker is still open: a real Escape closes it.
265+ writeTerminal(t, writer, "\x1b")
266+ harness.awaitAfterAmbiguityDeadlines(t, transport, wire.MsgPickerCloseClient)
267+}
268+
269+// TestPickerReleaseDropsInputWithoutReplay pins the release window: bytes
270+// arriving after the close are consumed and dropped, never replayed into
271+// the session, and routing resumes only after the authoritative paint.
272+func TestPickerReleaseDropsInputWithoutReplay(t *testing.T) {
273+ ctx := context.Background()
274+ reader, writer := io.Pipe()
275+ t.Cleanup(func() { _ = writer.Close() })
276+ harness := startPickerE2EWithInput(t, reader)
277+ ui, transport := harness.ui, harness.transport
278+
279+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(pickerSnapshot())}
280+ attached := ports.UIStatusAttached
281+ first := "first"
282+ _, err := ui.Wait(ctx, utilWait(ui, &attached, &first))
283+ require.NoError(t, err)
284+
285+ // Close the interaction from the daemon side, then keep typing: the
286+ // release paint has not landed, so those bytes belong to the picker.
287+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: 7, BarrierEpoch: 2, BarrierState: 1})}
288+ writeTerminal(t, writer, "leaked")
289+ requireNoPickerInput(t, transport)
290+
291+ // The authoritative full paint releases the terminal; later input is
292+ // session input again, and only the new bytes are delivered.
293+ require.NotNil(t, pickerReleasePaint(transport, "released"))
294+ released := "released"
295+ _, err = ui.Wait(ctx, utilWait(ui, &attached, &released))
296+ require.NoError(t, err, "the release paint must be displayed")
297+
298+ writeTerminal(t, writer, "ok")
299+ var delivered []byte
300+ deadline := time.After(2 * time.Second)
301+ for len(delivered) < len("ok") {
302+ select {
303+ case frame := <-transport.inputCh:
304+ delivered = append(delivered, frame.Data...)
305+ case <-deadline:
306+ t.Fatalf("session input did not resume after the release paint: got %q", delivered)
307+ }
308+ }
309+ require.Equal(t, []byte("ok"), delivered)
310+ select {
311+ case frame := <-transport.inputCh:
312+ t.Fatalf("released input was replayed: %q", frame.Data)
313+ case <-time.After(100 * time.Millisecond):
314+ }
315+}
316+
317+// writeTerminal feeds raw bytes without blocking the test when the pump is
318+// momentarily inactive.
319+func writeTerminal(t *testing.T, writer *io.PipeWriter, data string) {
320+ t.Helper()
321+ done := make(chan struct{})
322+ go func() {
323+ defer close(done)
324+ _, _ = writer.Write([]byte(data))
325+ }()
326+ select {
327+ case <-done:
328+ case <-time.After(2 * time.Second):
329+ t.Fatalf("terminal write %q never reached the pump", data)
330+ }
331+}
332+
333+// utilWait builds a UI wait request for one expected fragment.
334+func utilWait(ui *UI, status *ports.UIPresentationStatus, text *string) ports.UIWaitRequest {
335+ return ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: status, TextContains: text}}
336+}
A internal/usecase/client/picker_interaction.go +731 -0
@@ -0,0 +1,731 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ ansirenderer "github.com/bnema/vev-vt/ansi"
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+)
13+
14+// This file owns the client side of the picker interaction: source admission,
15+// local model ownership, and typed commit/cancel. It mirrors the
16+// inventoryRelay admission discipline (navigation_inventory.go) but never
17+// canonicalizes line order: picker order is semantically significant. The
18+// loop consumes terminal-pump records, never raw reads; commit sends a typed
19+// PickerSelection on the ordered control channel, never picker bytes toward
20+// the PTY.
21+
22+// pickerInteraction tracks one picker namespace: the admitted interaction ID
23+// and the newest interaction this attachment retired. A retired interaction is
24+// never resurrected by a late snapshot.
25+type pickerInteraction struct {
26+ open bool
27+ interaction uint64
28+ retired uint64
29+ // intent is the intent the serving daemon published for this interaction.
30+ // Move intents present destination rows instead of navigation rows.
31+ intent protocol.PickerIntent
32+ // sourceRevisions tracks the newest admitted revision per source. Every
33+ // source publishes its own revision, so one source's refresh never
34+ // invalidates a selection displayed from another.
35+ sourceRevisions map[string]uint64
36+}
37+
38+// setOpen starts or stops the interaction namespace. Superseding an open
39+// interaction retires it too: a late snapshot for the replaced ID must not
40+// take presentation back. Closing retires the namespace for good.
41+func (p *pickerInteraction) setOpen(open bool, interaction uint64) {
42+ if p == nil {
43+ return
44+ }
45+ if open && interaction != p.interaction && p.open && p.interaction > p.retired {
46+ p.retired = p.interaction
47+ }
48+ p.open = open
49+ if open && interaction != p.interaction {
50+ p.interaction = interaction
51+ p.sourceRevisions = make(map[string]uint64)
52+ }
53+ if !open && interaction > p.retired {
54+ p.retired = interaction
55+ }
56+ if !open {
57+ p.intent = 0
58+ p.sourceRevisions = nil
59+ }
60+}
61+
62+// setIntent records the intent published with the offer that opened the
63+// interaction.
64+func (p *pickerInteraction) setIntent(interaction uint64, intent protocol.PickerIntent) {
65+ if p == nil || !p.open || p.interaction != interaction {
66+ return
67+ }
68+ p.intent = intent
69+}
70+
71+// admitSnapshot validates one source publication against the open interaction
72+// and reports whether it carries a newer revision worth displaying. Retired
73+// interactions, foreign interactions, and older or duplicate source revisions
74+// discard.
75+func (p *pickerInteraction) admitSnapshot(snapshot protocol.PickerSnapshot, wantsEvent bool) bool {
76+ if p == nil {
77+ return false
78+ }
79+ if protocol.ValidatePickerSnapshot(snapshot) != nil {
80+ return false
81+ }
82+ if snapshot.InteractionID <= p.retired {
83+ return false
84+ }
85+ if !p.open || snapshot.InteractionID != p.interaction {
86+ return false
87+ }
88+ if snapshot.SourceRevision <= p.sourceRevisions[snapshot.SourceID] {
89+ return false
90+ }
91+ if wantsEvent && p.intent == 0 {
92+ // The offer carries the intent; a snapshot that arrives first is
93+ // still admitted so a lost offer cannot wedge the interaction.
94+ }
95+ p.sourceRevisions[snapshot.SourceID] = snapshot.SourceRevision
96+ return true
97+}
98+
99+// pickerLoop owns one admitted *picker.Model plus the source publication it
100+// was built from. Cursor, search, and sort are local-only presentation;
101+// commit and cancel cross the wire as typed messages. The daemon revalidates
102+// the committed source revision and key, so the client never keeps a second
103+// copy of the row set.
104+type pickerLoop struct {
105+ model *picker.Model
106+ interaction uint64
107+ intent protocol.PickerIntent
108+ sources map[string]protocol.PickerSnapshot
109+ order []string
110+ rows map[string]pickerRowIdentity
111+}
112+
113+type pickerRowIdentity struct {
114+ sourceID string
115+ sourceRevision uint64
116+ key string
117+}
118+
119+// pickerLoopFromSnapshot builds the local model from the first admitted source.
120+// Source-owned opaque keys are namespaced only inside the presentation model;
121+// commits translate them back to the exact source identity published on wire.
122+func pickerLoopFromSnapshot(snapshot protocol.PickerSnapshot, intent protocol.PickerIntent, sort picker.SortMode) *pickerLoop {
123+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
124+ loop := &pickerLoop{interaction: snapshot.InteractionID, intent: intent, sources: make(map[string]protocol.PickerSnapshot)}
125+ loop.sources[snapshot.SourceID] = snapshot
126+ loop.order = append(loop.order, snapshot.SourceID)
127+ projection := pickerSnapshotProjection(snapshot, sort)
128+ cursor := projection.Cursor
129+ if cursor.Key != "" {
130+ cursor.Key = fmt.Sprintf("%d:%s:%s", len(snapshot.SourceID), snapshot.SourceID, cursor.Key)
131+ }
132+ loop.rebuild(cursor, sort)
133+ return loop
134+}
135+
136+// replaceLines applies one source publication while retaining every other
137+// source. Independent source revisions therefore cannot invalidate each other.
138+func (l *pickerLoop) replaceLines(snapshot protocol.PickerSnapshot) {
139+ snapshot = protocol.NormalizePickerSnapshot(snapshot)
140+ if l == nil || l.model == nil || snapshot.InteractionID != l.interaction {
141+ return
142+ }
143+ if _, exists := l.sources[snapshot.SourceID]; !exists {
144+ l.order = append(l.order, snapshot.SourceID)
145+ }
146+ selectedKey := ""
147+ if selected, ok := l.model.Selected(); ok {
148+ selectedKey = selected.Key
149+ }
150+ l.sources[snapshot.SourceID] = snapshot
151+ l.rebuild(protocol.PickerCursor{Key: selectedKey, Index: -1}, l.model.SortMode(), true)
152+}
153+
154+func (l *pickerLoop) rebuild(cursor protocol.PickerCursor, sort picker.SortMode, preserveEditor ...bool) {
155+ lines := make([]protocol.PickerLine, 0)
156+ rows := make(map[string]pickerRowIdentity)
157+ cursorKey := ""
158+ for _, sourceID := range l.order {
159+ snapshot, ok := l.sources[sourceID]
160+ if !ok {
161+ continue
162+ }
163+ projection := pickerSnapshotProjection(snapshot, sort)
164+ for index, published := range projection.Lines {
165+ line := published
166+ if line.Key != "" {
167+ presented := fmt.Sprintf("%d:%s:%s", len(sourceID), sourceID, line.Key)
168+ rows[presented] = pickerRowIdentity{sourceID: sourceID, sourceRevision: snapshot.SourceRevision, key: line.Key}
169+ if presented == cursor.Key || cursor.Key == "" && sourceID == snapshot.SourceID && index == projection.Cursor.Index {
170+ cursorKey = presented
171+ }
172+ line.Key = presented
173+ }
174+ lines = append(lines, line)
175+ }
176+ }
177+ l.rows = rows
178+ modelCursor := protocol.PickerCursor{Key: cursorKey, Index: -1}
179+ if len(preserveEditor) != 0 && preserveEditor[0] && l.model != nil {
180+ l.model.ReplaceProjection(lines, modelCursor, sort)
181+ return
182+ }
183+ l.model = picker.New(lines, picker.Config{Intent: l.intent, Cursor: modelCursor, Sort: sort})
184+}
185+
186+// up moves the cursor one focusable row up.
187+func (l *pickerLoop) up() {
188+ if l == nil || l.model == nil {
189+ return
190+ }
191+ l.model.Up()
192+}
193+
194+// down moves the cursor one focusable row down.
195+func (l *pickerLoop) down() {
196+ if l == nil || l.model == nil {
197+ return
198+ }
199+ l.model.Down()
200+}
201+
202+// enterSearch begins search mode; text inserts through insert().
203+func (l *pickerLoop) enterSearch() {
204+ if l == nil || l.model == nil {
205+ return
206+ }
207+ if !l.model.SearchActive() {
208+ l.model.EnterSearch()
209+ }
210+}
211+
212+// insert types one rune into the active search, entering search first.
213+func (l *pickerLoop) insert(r rune) {
214+ if l == nil || l.model == nil {
215+ return
216+ }
217+ l.enterSearch()
218+ l.model.InsertSearch(r)
219+}
220+
221+// backspace deletes one search rune; Escape handling distinguishes
222+// search-exit from picker-close at the call site.
223+func (l *pickerLoop) backspace() {
224+ if l == nil || l.model == nil {
225+ return
226+ }
227+ l.model.BackspaceSearch()
228+}
229+
230+// escape handles one Escape press: exit search when searching, else report
231+// picker-close. The boolean is true when the caller must send PickerClose.
232+func (l *pickerLoop) escape() bool {
233+ if l == nil || l.model == nil {
234+ return true
235+ }
236+ if l.model.SearchActive() {
237+ l.model.ExitSearch()
238+ return false
239+ }
240+ return true
241+}
242+
243+// toggleSort flips the local ordering between recency and grouped and reports
244+// whether the display changed.
245+func (l *pickerLoop) toggleSort() bool {
246+ if l == nil || l.model == nil {
247+ return false
248+ }
249+ next := picker.SortGrouped
250+ if l.model.SortMode() == picker.SortGrouped {
251+ next = picker.SortRecent
252+ }
253+ selected := protocol.PickerCursor{Index: -1}
254+ if line, ok := l.model.Selected(); ok {
255+ selected.Key = line.Key
256+ }
257+ l.rebuild(selected, next, true)
258+ return true
259+}
260+
261+func pickerSnapshotProjection(snapshot protocol.PickerSnapshot, sort picker.SortMode) protocol.PickerProjection {
262+ if sort == picker.SortGrouped {
263+ return snapshot.Grouped
264+ }
265+ return snapshot.Recent
266+}
267+
268+// selectedAction reports the action the displayed row authorises: navigation
269+// rows navigate, move rows move. The daemon publishes the bits, so the client
270+// never invents authority.
271+func (l *pickerLoop) selectedAction() (protocol.PickerAction, bool) {
272+ if l == nil || l.model == nil {
273+ return 0, false
274+ }
275+ line, ok := l.model.Selected()
276+ if !ok {
277+ return 0, false
278+ }
279+ switch {
280+ case line.Actions&protocol.PickerCanNavigate != 0:
281+ return protocol.PickerActionNavigate, true
282+ case line.Actions&protocol.PickerCanMove != 0:
283+ return protocol.PickerActionMove, true
284+ default:
285+ return 0, false
286+ }
287+}
288+
289+// killAction reports whether the cursor row authorises destruction.
290+func (l *pickerLoop) killAction() bool {
291+ if l == nil || l.model == nil {
292+ return false
293+ }
294+ line, ok := l.model.Selected()
295+ return ok && line.Actions&protocol.PickerCanKill != 0
296+}
297+
298+// defaultPickerSort is the client-local initial ordering mode.
299+func defaultPickerSort() picker.SortMode { return picker.SortRecent }
300+
301+// pickerPreviewSize is the viewport the modal can display for one terminal: the
302+// preview pane inside the shared picker geometry.
303+func pickerPreviewSize(terminal domain.Size) domain.Size {
304+ return picker.Size(picker.PreviewRect(terminal))
305+}
306+
307+// emptyPickerPreview is the zero preview used until the daemon publishes one.
308+func emptyPickerPreview() picker.Preview { return picker.Preview{} }
309+
310+// cursorKey names the row the modal currently displays. The daemon revalidates
311+// it against the interaction it published, so it stays opaque here.
312+func (l *pickerLoop) cursorKey() string {
313+ if l == nil || l.model == nil {
314+ return ""
315+ }
316+ line, ok := l.model.Selected()
317+ if !ok {
318+ return ""
319+ }
320+ identity, ok := l.rows[line.Key]
321+ if !ok {
322+ return ""
323+ }
324+ return identity.key
325+}
326+
327+// pickerPreviewDebounce bounds how long the cursor may rest before the client
328+// asks the serving daemon for that row's preview. The daemon keeps publishing
329+// the row afterwards, so this only throttles cursor movement.
330+const pickerPreviewDebounce = 80 * time.Millisecond
331+
332+// pickerPreviewClient owns the client half of the row preview: the row the
333+// modal displays, the row whose request is on the wire, and the viewport the
334+// daemon last published for it. The client never captures a viewport itself.
335+type pickerPreviewClient struct {
336+ interaction uint64
337+ sent string
338+ frame picker.Preview
339+}
340+
341+// resetFor drops the preview state of one retired interaction. A released
342+// interaction never shows the previous row's viewport again.
343+func (p *pickerPreviewClient) resetFor() {
344+ if p == nil {
345+ return
346+ }
347+ *p = pickerPreviewClient{}
348+}
349+
350+// needsRequest reports whether the displayed row still needs a request: a new
351+// interaction, a moved cursor, or a row the daemon has not been asked for yet.
352+func (p *pickerPreviewClient) needsRequest(interaction uint64, key string) bool {
353+ if p == nil || key == "" || interaction == 0 {
354+ return false
355+ }
356+ return p.interaction != interaction || p.sent != key
357+}
358+
359+// requestFor builds the request for one displayed row. The client asks for
360+// exactly the viewport it can display, bounded by the preview protocol.
361+func (p *pickerPreviewClient) requestFor(interaction uint64, key string, size domain.Size) (protocol.PickerPreviewRequest, bool) {
362+ request := protocol.PickerPreviewRequest{
363+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: interaction,
364+ SourceID: protocol.PickerServingSourceID, Key: key,
365+ Width: clampPreviewDimension(size.Cols, protocol.PickerPreviewMaxWidth),
366+ Height: clampPreviewDimension(size.Rows, protocol.PickerPreviewMaxHeight),
367+ }
368+ if protocol.ValidatePickerPreviewRequest(request) != nil {
369+ return protocol.PickerPreviewRequest{}, false
370+ }
371+ return request, true
372+}
373+
374+// markSent records the row whose request crossed the wire. The next debounce
375+// fires only when the cursor moved again.
376+func (p *pickerPreviewClient) markSent(interaction uint64, key string) {
377+ if p == nil {
378+ return
379+ }
380+ p.interaction, p.sent = interaction, key
381+}
382+
383+// accept applies one published preview and reports whether it describes the
384+// displayed row. A late answer for a previous row leaves the current viewport
385+// untouched, and a status-only answer clears it.
386+func (p *pickerPreviewClient) accept(preview protocol.PickerPreview, interaction uint64, key string) bool {
387+ if p == nil || key == "" || preview.InteractionID != interaction || preview.Key != key {
388+ return false
389+ }
390+ if preview.Status != protocol.PickerPreviewOK {
391+ p.frame = emptyPickerPreview()
392+ return true
393+ }
394+ rows := preview.FrameRows()
395+ if rows == nil {
396+ return false
397+ }
398+ p.frame = picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
399+ return true
400+}
401+
402+// clampPreviewDimension bounds one requested viewport dimension.
403+func clampPreviewDimension(value int, maxValue uint16) uint16 {
404+ if value <= 0 {
405+ return 1
406+ }
407+ if value > int(maxValue) {
408+ return maxValue
409+ }
410+ return uint16(value)
411+}
412+
413+// pickerRenderer encodes one picker frame to terminal bytes. It owns a
414+// dedicated ANSI renderer shadow (never the daemon output shadow): the
415+// client-picker frame is a local presentation overlay, not daemon output,
416+// so its bytes never enter the output ACK window.
417+type pickerRenderer struct {
418+ renderer *ansirenderer.Renderer
419+ size domain.Size
420+ // prevBounds is the box drawn last: when it moves or resizes, its previous
421+ // cells are blanked together with the new ones, and the rest of the screen
422+ // stays untouched.
423+ prevBounds *domain.Rect
424+ // primed records that the renderer shadow was seeded with the screen as it
425+ // is, so the first box is diffed against it instead of replacing the screen.
426+ primed bool
427+}
428+
429+func newPickerRenderer() *pickerRenderer {
430+ return &pickerRenderer{}
431+}
432+
433+// render composes the loop model into terminal bytes for one display
434+// refresh. Previews arrive as source data (picker_preview.go) and are attached
435+// to the model by the caller; a full redraw every refresh keeps the shadow
436+// trivially consistent: the frame is small and modal, never a PTY stream.
437+// render composes one floating modal over the session: the picker never takes
438+// the whole screen. The box is resolved from the shared picker geometry, its
439+// inner content is rendered into that rectangle, and only the box's own cells
440+// are written, so the session stays visible around it until the daemon's
441+// authoritative repaint after the close.
442+func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview picker.Preview) []byte {
443+ if r == nil || loop == nil || loop.model == nil {
444+ return nil
445+ }
446+ if size.Cols <= 0 || size.Rows <= 0 {
447+ return nil
448+ }
449+ if r.renderer == nil || r.size != size {
450+ r.renderer = ansirenderer.New(ansirenderer.Capabilities{})
451+ r.size = size
452+ r.prevBounds = nil
453+ r.primed = false
454+ }
455+ // The picker is a floating box over a screen the client does not own: a
456+ // fresh renderer would repaint the whole terminal from its empty shadow and
457+ // wipe the session. Seed the shadow with the empty screen instead, without
458+ // emitting anything, so only the box's own cells are ever written.
459+ if !r.primed {
460+ if prepared, err := r.renderer.Prepare(renderer.NewFrame(size.Cols, size.Rows), nil, false); err == nil {
461+ prepared.Commit()
462+ r.primed = true
463+ }
464+ }
465+ presentation := picker.Modal.Resolve(size)
466+ base := renderer.NewFrame(size.Cols, size.Rows)
467+ border := renderer.DefaultStyle()
468+ border.Attrs |= renderer.AttrDim
469+ picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
470+ inner := loop.model.Render(picker.Size(presentation.Inner), preview)
471+ copyFrameRect(base, presentation.Inner, inner)
472+
473+ // Only the box is written: the session stays on screen around it. A box
474+ // that moved (a resize, or a wider title) blanks the cells it used to own.
475+ var damage []ansirenderer.Damage
476+ if r.prevBounds != nil && *r.prevBounds != presentation.Bounds {
477+ damage = append(damage, damageRect(*r.prevBounds))
478+ }
479+ damage = append(damage, damageRect(presentation.Bounds))
480+ bounds := presentation.Bounds
481+ r.prevBounds = &bounds
482+
483+ data, err := r.renderer.Draw(base, damage)
484+ if err != nil {
485+ return nil
486+ }
487+ return data
488+}
489+
490+// damageRect is the damage one rectangle of a composed frame produces.
491+func damageRect(rect domain.Rect) ansirenderer.Damage {
492+ return ansirenderer.Damage{
493+ Kind: renderer.DamageText,
494+ X: rect.X, Y: rect.Y,
495+ Width: rect.Width, Height: rect.Height, Count: 1,
496+ }
497+}
498+
499+// copyFrameRect blits src into dst at the target rectangle, clipped to both.
500+func copyFrameRect(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
501+ left := max(target.X, 0)
502+ top := max(target.Y, 0)
503+ right := min(target.X+target.Width, dst.Width)
504+ bottom := min(target.Y+target.Height, dst.Height)
505+ if left >= right || top >= bottom {
506+ return
507+ }
508+ sourceX := left - target.X
509+ sourceY := top - target.Y
510+ width := min(right-left, src.Width-sourceX)
511+ height := min(bottom-top, src.Height-sourceY)
512+ if width <= 0 || height <= 0 {
513+ return
514+ }
515+ for y := range height {
516+ for x := range width {
517+ dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
518+ }
519+ }
520+}
521+
522+// commitSelection builds the typed commit for the row under the cursor at the
523+// displayed source revision. Zero picker bytes travel toward the PTY: the
524+// opaque key plus revision ride a PickerSelection on the ordered control
525+// channel and the owning source resolves them.
526+func commitSelection(loop *pickerLoop, action protocol.PickerAction, causeActionID uint64) (protocol.PickerSelection, bool) {
527+ if loop == nil || loop.model == nil {
528+ return protocol.PickerSelection{}, false
529+ }
530+ line, ok := loop.model.Selected()
531+ if !ok {
532+ return protocol.PickerSelection{}, false
533+ }
534+ identity, ok := loop.rows[line.Key]
535+ if !ok {
536+ return protocol.PickerSelection{}, false
537+ }
538+ selection := protocol.PickerSelection{
539+ CauseActionID: causeActionID, InteractionID: loop.interaction,
540+ SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
541+ Key: identity.key, Action: action,
542+ }
543+ if protocol.ValidatePickerSelection(selection) != nil {
544+ return protocol.PickerSelection{}, false
545+ }
546+ return selection, true
547+}
548+
549+// killSelection builds the typed kill for the row under the cursor. It is a
550+// distinct commit so the source can verify the row still authorises
551+// destruction.
552+func killSelection(loop *pickerLoop, causeActionID uint64) (protocol.PickerSelection, bool) {
553+ if loop == nil || loop.model == nil {
554+ return protocol.PickerSelection{}, false
555+ }
556+ line, ok := loop.model.Selected()
557+ if !ok || line.Actions&protocol.PickerCanKill == 0 {
558+ return protocol.PickerSelection{}, false
559+ }
560+ identity, ok := loop.rows[line.Key]
561+ if !ok {
562+ return protocol.PickerSelection{}, false
563+ }
564+ selection := protocol.PickerSelection{
565+ CauseActionID: causeActionID, InteractionID: loop.interaction,
566+ SourceID: identity.sourceID, SourceRevision: identity.sourceRevision,
567+ Key: identity.key, Action: protocol.PickerActionKill,
568+ }
569+ if protocol.ValidatePickerSelection(selection) != nil {
570+ return protocol.PickerSelection{}, false
571+ }
572+ return selection, true
573+}
574+
575+// pickerOp is the presentation decision one driver operation asks the attach
576+// loop to carry out. Close wins over commit when both land in one op,
577+// mirroring the overlay exit short-circuit.
578+type pickerOp struct {
579+ commit bool
580+ kill bool
581+ close bool
582+}
583+
584+// pickerDriverOp is one ui-driver operation applied to an open loop. Keys
585+// drive cursor/search/exit: arrows and j/k move in normal mode (j/k are
586+// literal in search), `/` enters search, `s` reorders locally, `x` asks to
587+// destroy the cursor row, `q`/Ctrl-C/Escape close in normal mode (Escape
588+// exits search first), Backspace edits the query. Text runes insert only
589+// while search is active; normal-mode typing is ignored exactly like the
590+// overlay.
591+func pickerDriverOp(loop *pickerLoop, keys []string, text string) (op pickerOp, changed bool) {
592+ if loop == nil || loop.model == nil {
593+ return pickerOp{}, false
594+ }
595+ insertLit := func(r rune) {
596+ if loop.model.SearchActive() {
597+ loop.insert(r)
598+ }
599+ }
600+ for _, key := range keys {
601+ active := loop.model.SearchActive()
602+ beforeIndex, beforeSearch := loop.model.SelectedIndex(), active
603+ beforeQuery := loop.model.Query()
604+ switch key {
605+ case "Up":
606+ loop.up()
607+ case "Down":
608+ loop.down()
609+ case "j":
610+ if active {
611+ loop.insert('j')
612+ } else {
613+ loop.down()
614+ }
615+ case "k":
616+ if active {
617+ loop.insert('k')
618+ } else {
619+ loop.up()
620+ }
621+ case "Enter":
622+ // Commit is typed at the call site via commitSelection; the
623+ // key itself sends zero bytes toward the PTY.
624+ op.commit = true
625+ case "Escape":
626+ if loop.escape() {
627+ return pickerOp{close: true}, changed
628+ }
629+ case "Backspace":
630+ if active {
631+ loop.backspace()
632+ }
633+ case "/":
634+ if active {
635+ loop.insert('/')
636+ } else {
637+ loop.enterSearch()
638+ }
639+ case "s":
640+ if active {
641+ loop.insert('s')
642+ } else {
643+ changed = loop.toggleSort() || changed
644+ }
645+ case "x":
646+ if active {
647+ loop.insert('x')
648+ } else {
649+ op.kill = true
650+ }
651+ case "q":
652+ if active {
653+ loop.insert('q')
654+ } else {
655+ return pickerOp{close: true}, changed
656+ }
657+ case "Ctrl+C":
658+ // The overlay exits on Ctrl-C in both modes; the byte
659+ // itself never reaches the query.
660+ return pickerOp{close: true}, changed
661+ default:
662+ if len(key) == 1 {
663+ insertLit(rune(key[0]))
664+ }
665+ }
666+ if loop.model.SelectedIndex() != beforeIndex || loop.model.SearchActive() != beforeSearch || loop.model.Query() != beforeQuery {
667+ changed = true
668+ }
669+ }
670+ for _, r := range text {
671+ if loop.model.SearchActive() {
672+ loop.insert(r)
673+ changed = true
674+ }
675+ }
676+ return op, changed
677+}
678+
679+// pickerInputBatch is one decoded batch: ordered input events.
680+type pickerInputBatch struct {
681+ events []pickerEvent
682+}
683+
684+// pickerConsumeOutcome is one decoded operation reported by the stdin
685+// pump to the attach loop. The pump decodes and identifies; the attach
686+// loop is the only writer of the picker model, so the outcome carries the
687+// ordered events and their interaction identity, never a mutation or a
688+// message. actionID echoes the admitted record so the loop completes the
689+// right ui-driver action.
690+type pickerConsumeOutcome struct {
691+ consumed bool
692+ actionID uint64
693+ generation uint64
694+ interaction uint64
695+ events []pickerEvent
696+}
697+
698+// acceptOutcome reports whether this operation still belongs to the
699+// interaction the attachment currently presents. Retired generations and
700+// interactions drop.
701+func (o pickerConsumeOutcome) acceptOutcome(interaction, generation uint64) bool {
702+ return o.consumed && o.interaction != 0 && o.interaction == interaction && o.generation == generation
703+}
704+
705+// applyPickerBatch applies one decoded batch in arrival order. Every event
706+// is applied: a terminal read can legitimately carry several keystrokes, so
707+// batch size never decides whether input is a command. Paste protection is
708+// the decoder's paste state, not a heuristic here: a bracketed paste's
709+// content never reaches this function.
710+func applyPickerBatch(loop *pickerLoop, events []pickerEvent) (op pickerOp, changed bool) {
711+ if loop == nil || loop.model == nil || len(events) == 0 {
712+ return pickerOp{}, false
713+ }
714+ for _, event := range events {
715+ if event.kind == pickerEventRune {
716+ if loop.model.SearchActive() {
717+ loop.insert(event.r)
718+ changed = true
719+ }
720+ continue
721+ }
722+ eventOp, eventChanged := pickerDriverOp(loop, []string{event.key}, "")
723+ changed = changed || eventChanged
724+ if eventOp.close {
725+ return pickerOp{close: true}, changed
726+ }
727+ op.commit = op.commit || eventOp.commit
728+ op.kill = op.kill || eventOp.kill
729+ }
730+ return op, changed
731+}
A internal/usecase/client/picker_interaction_test.go +298 -0
@@ -0,0 +1,298 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func pickerSnapshotFixture() protocol.PickerSnapshot {
14+ return protocol.PickerSnapshot{
15+ InteractionID: 11, SourceID: "serving", SourceRevision: 3, Status: protocol.PickerSourceOK,
16+ Lines: []protocol.PickerLine{
17+ {Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work", Detail: "2 tabs", Focusable: true, Actions: protocol.PickerCanNavigate | protocol.PickerCanKill},
18+ {Key: "bb/perso", Kind: protocol.PickerLineSession, Label: "perso", Detail: "stopped", Stopped: true, Focusable: true, Actions: protocol.PickerCanNavigate},
19+ {Key: "cc/remote", Kind: protocol.PickerLineSession, Label: "remote@host", Focusable: true, Actions: protocol.PickerCanNavigate},
20+ },
21+ Cursor: protocol.PickerCursor{Key: "aa/work", Index: 0},
22+ }
23+}
24+
25+func pickerLoopFixture(t *testing.T) *pickerLoop {
26+ t.Helper()
27+ return pickerLoopFromSnapshot(pickerSnapshotFixture(), protocol.PickerIntentNavigation, picker.SortRecent)
28+}
29+
30+func pickedKey(t *testing.T, loop *pickerLoop) string {
31+ t.Helper()
32+ selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)
33+ require.True(t, ok)
34+ return selection.Key
35+}
36+
37+func TestPickerInteractionAdmitsLatestSourceRevision(t *testing.T) {
38+ var rel *pickerInteraction
39+ require.False(t, rel.admitSnapshot(pickerSnapshotFixture(), false))
40+
41+ rel = &pickerInteraction{}
42+ snapshot := pickerSnapshotFixture()
43+ require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction must not admit")
44+
45+ rel.setOpen(true, snapshot.InteractionID)
46+ require.True(t, rel.admitSnapshot(snapshot, false))
47+ require.Equal(t, snapshot.SourceRevision, rel.sourceRevisions[snapshot.SourceID])
48+
49+ older := snapshot
50+ older.SourceRevision = snapshot.SourceRevision - 1
51+ require.False(t, rel.admitSnapshot(older, false), "stale source revision must not step backward")
52+
53+ foreign := snapshot
54+ foreign.InteractionID = snapshot.InteractionID + 1
55+ require.False(t, rel.admitSnapshot(foreign, false), "foreign interaction must drop")
56+
57+ rel.setOpen(false, snapshot.InteractionID)
58+ require.False(t, rel.admitSnapshot(snapshot, false), "closed interaction drops late snapshots")
59+}
60+
61+func TestPickerInteractionKeepsPerSourceRevisions(t *testing.T) {
62+ rel := &pickerInteraction{}
63+ snapshot := pickerSnapshotFixture()
64+ rel.setOpen(true, snapshot.InteractionID)
65+ require.True(t, rel.admitSnapshot(snapshot, false))
66+
67+ // Another source publishes its own first revision: it must be admitted
68+ // even though the serving source is far ahead.
69+ other := snapshot
70+ other.SourceID = "host-2"
71+ other.SourceRevision = 1
72+ require.True(t, rel.admitSnapshot(other, false))
73+ require.Equal(t, uint64(3), rel.sourceRevisions["serving"])
74+ require.Equal(t, uint64(1), rel.sourceRevisions["host-2"])
75+}
76+
77+func TestPickerLoopMergesSourcesAndCommitsOwningIdentity(t *testing.T) {
78+ local := pickerSnapshotFixture()
79+ local.SourceID = "authority/local"
80+ local.Lines = local.Lines[:1]
81+ local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
82+ loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
83+
84+ remote := local
85+ remote.SourceID = "authority/remote-a"
86+ remote.SourceRevision = 1
87+ remote.Lines = []protocol.PickerLine{{Key: "aa/work", Kind: protocol.PickerLineSession, Label: "work@remote-a", Focusable: true, Actions: protocol.PickerCanNavigate}}
88+ remote.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
89+ loop.replaceLines(remote)
90+
91+ loop.down()
92+ selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 9)
93+ require.True(t, ok)
94+ require.Equal(t, "authority/remote-a", selection.SourceID)
95+ require.Equal(t, uint64(1), selection.SourceRevision)
96+ require.Equal(t, "aa/work", selection.Key)
97+}
98+
99+func TestPickerLoopRefreshesOneSourceWithoutInvalidatingAnother(t *testing.T) {
100+ local := pickerSnapshotFixture()
101+ local.SourceID = "authority/local"
102+ local.Lines = local.Lines[:1]
103+ local.Cursor = protocol.PickerCursor{Key: "aa/work", Index: 0}
104+ loop := pickerLoopFromSnapshot(local, protocol.PickerIntentNavigation, picker.SortRecent)
105+
106+ remote := local
107+ remote.SourceID = "authority/remote-a"
108+ remote.SourceRevision = 1
109+ remote.Lines = []protocol.PickerLine{{Key: "remote/session", Kind: protocol.PickerLineSession, Label: "remote", Focusable: true, Actions: protocol.PickerCanNavigate}}
110+ remote.Cursor = protocol.PickerCursor{Key: "remote/session", Index: 0}
111+ loop.replaceLines(remote)
112+ loop.down()
113+
114+ local.SourceRevision++
115+ local.Lines[0].Detail = "refreshed"
116+ loop.replaceLines(local)
117+ selection, ok := commitSelection(loop, protocol.PickerActionNavigate, 0)
118+ require.True(t, ok)
119+ require.Equal(t, "authority/remote-a", selection.SourceID)
120+ require.Equal(t, uint64(1), selection.SourceRevision)
121+}
122+
123+func TestPickerInteractionRetirementIsPermanent(t *testing.T) {
124+ snapshot := pickerSnapshotFixture()
125+ var rel pickerInteraction
126+ rel.setOpen(true, snapshot.InteractionID)
127+ require.True(t, rel.admitSnapshot(snapshot, false))
128+
129+ // Closing retires the namespace: an in-flight snapshot for the same
130+ // interaction can never reopen it.
131+ rel.setOpen(false, snapshot.InteractionID)
132+ require.Equal(t, snapshot.InteractionID, rel.retired)
133+ require.False(t, rel.admitSnapshot(snapshot, false))
134+ rel.setOpen(true, snapshot.InteractionID)
135+ require.False(t, rel.admitSnapshot(snapshot, false), "a retired interaction is never reopened")
136+
137+ // A newer interaction is a new namespace and admits normally.
138+ next := snapshot
139+ next.InteractionID = snapshot.InteractionID + 1
140+ next.SourceRevision = 1
141+ rel.setOpen(true, next.InteractionID)
142+ require.True(t, rel.admitSnapshot(next, false))
143+ require.False(t, rel.admitSnapshot(snapshot, false), "the retired namespace stays closed")
144+}
145+
146+func TestPickerLoopCursorSearchCommit(t *testing.T) {
147+ loop := pickerLoopFixture(t)
148+ require.NotNil(t, loop.model)
149+
150+ // Cursor starts at the published cursor key (first row).
151+ require.Equal(t, "aa/work", pickedKey(t, loop))
152+
153+ // Cursor movement selects the non-initial row: the committed key follows
154+ // the client cursor, never the daemon cursor.
155+ loop.down()
156+ require.Equal(t, "bb/perso", pickedKey(t, loop))
157+ loop.down()
158+ require.Equal(t, "cc/remote", pickedKey(t, loop))
159+ loop.up()
160+ require.Equal(t, "bb/perso", pickedKey(t, loop))
161+}
162+
163+func TestPickerLoopSearchExitVsClose(t *testing.T) {
164+ loop := pickerLoopFixture(t)
165+
166+ // Text enters search; Escape exits search without closing.
167+ loop.insert('w')
168+ require.True(t, loop.model.SearchActive())
169+ require.False(t, loop.escape(), "search-exit Escape must not close")
170+ require.False(t, loop.model.SearchActive())
171+
172+ // Escape with no search reports picker-close.
173+ require.True(t, loop.escape(), "idle Escape must close")
174+
175+ // Literal s/x in search are text; backspace deletes.
176+ loop.insert('s')
177+ loop.insert('x')
178+ require.True(t, loop.model.SearchActive())
179+ require.Equal(t, "sx", loop.model.Query())
180+ loop.backspace()
181+ require.Equal(t, "s", loop.model.Query())
182+}
183+
184+func TestPickerLoopKillRequiresAuthorisedRow(t *testing.T) {
185+ loop := pickerLoopFixture(t)
186+
187+ // The first row authorises destruction; its typed kill carries the
188+ // displayed source revision and the fatal action.
189+ selection, ok := killSelection(loop, 0)
190+ require.True(t, ok)
191+ require.Equal(t, protocol.PickerActionKill, selection.Action)
192+ require.Equal(t, "aa/work", selection.Key)
193+ require.Equal(t, uint64(3), selection.SourceRevision)
194+
195+ // The stopped row is navigable but not destructible from here.
196+ loop.down()
197+ require.Equal(t, "bb/perso", pickedKey(t, loop))
198+ _, ok = killSelection(loop, 0)
199+ require.False(t, ok, "a row without the kill action must not be destroyed")
200+}
201+
202+func TestPickerLoopKeepsPublishedLineOrderAndKeys(t *testing.T) {
203+ snapshot := pickerSnapshotFixture()
204+ loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
205+
206+ // Every published line commits its own opaque key without parsing display
207+ // text, in published order.
208+ loop.model.SelectNearestRow(0)
209+ keys := []string{pickedKey(t, loop)}
210+ for range len(snapshot.Lines) - 1 {
211+ loop.down()
212+ keys = append(keys, pickedKey(t, loop))
213+ }
214+ require.Equal(t, []string{"aa/work", "bb/perso", "cc/remote"}, keys)
215+}
216+
217+func TestPickerDriverOpReportsActionsAndLocalSort(t *testing.T) {
218+ loop := pickerLoopFixture(t)
219+
220+ // Normal mode: arrows and j/k move; Enter asks to commit; x asks to kill.
221+ op, changed := pickerDriverOp(loop, []string{"Down"}, "")
222+ require.False(t, op.commit)
223+ require.False(t, op.close)
224+ require.True(t, changed)
225+ require.Equal(t, "bb/perso", pickedKey(t, loop))
226+ op, _ = pickerDriverOp(loop, []string{"k"}, "")
227+ require.False(t, op.commit)
228+ require.Equal(t, "aa/work", pickedKey(t, loop))
229+
230+ op, _ = pickerDriverOp(loop, []string{"Enter"}, "")
231+ require.True(t, op.commit)
232+ op, _ = pickerDriverOp(loop, []string{"x"}, "")
233+ require.True(t, op.kill)
234+ require.False(t, op.close)
235+
236+ // q and Ctrl+C close in normal mode.
237+ for _, key := range []string{"q", "Ctrl+C"} {
238+ op, _ = pickerDriverOp(loop, []string{key}, "")
239+ require.True(t, op.close, "%s must close the picker", key)
240+ }
241+
242+ // s reorders locally without asking the daemon, and keeps the selection.
243+ op, changed = pickerDriverOp(loop, []string{"s"}, "")
244+ require.False(t, op.commit)
245+ require.False(t, op.kill)
246+ require.False(t, op.close)
247+ require.True(t, changed)
248+ require.Equal(t, picker.SortGrouped, loop.model.SortMode())
249+ require.Equal(t, "aa/work", pickedKey(t, loop))
250+
251+ // While searching, Enter still commits and printable keys are text.
252+ loop.insert('w')
253+ _, changed = pickerDriverOp(loop, []string{"o", "r"}, "")
254+ require.True(t, changed)
255+ require.Equal(t, "wor", loop.model.Query())
256+}
257+
258+// TestPickerPreviewClientTracksTheDisplayedRow pins the client half of the
259+// preview: one request per settled row, the daemon's own source identity, a
260+// bounded viewport ask, and a late answer that never replaces the displayed one.
261+func TestPickerPreviewClientTracksTheDisplayedRow(t *testing.T) {
262+ var preview pickerPreviewClient
263+ require.False(t, preview.needsRequest(7, ""), "a row without a key needs no request")
264+ require.True(t, preview.needsRequest(7, "aa/first"))
265+
266+ request, ok := preview.requestFor(7, "aa/first", domain.Size{Cols: 500, Rows: 0})
267+ require.True(t, ok)
268+ require.Equal(t, protocol.PickerPreviewSchemaVersion, request.Version)
269+ require.Equal(t, uint64(7), request.InteractionID)
270+ require.Equal(t, protocol.PickerServingSourceID, request.SourceID)
271+ require.Equal(t, uint16(protocol.PickerPreviewMaxWidth), request.Width, "the client asks for what it can display")
272+ require.Equal(t, uint16(1), request.Height, "a degenerate size still asks for one row")
273+
274+ preview.markSent(7, "aa/first")
275+ require.False(t, preview.needsRequest(7, "aa/first"), "a row already requested is not asked twice")
276+ require.True(t, preview.needsRequest(7, "bb/second"))
277+ require.True(t, preview.needsRequest(8, "aa/first"), "a new interaction asks again")
278+
279+ viewport := protocol.PickerPreview{
280+ Version: protocol.PickerPreviewSchemaVersion, InteractionID: 7, SourceID: protocol.PickerServingSourceID,
281+ Key: "aa/first", Status: protocol.PickerPreviewOK, Width: 2, Height: 1,
282+ Cells: []renderer.Cell{{Rune: 'o'}, {Rune: 'k'}},
283+ }
284+ require.False(t, preview.accept(viewport, 7, "bb/second"), "a late answer must not replace the displayed row")
285+ require.True(t, preview.accept(viewport, 7, "aa/first"))
286+ require.Equal(t, 2, preview.frame.Width)
287+ require.Equal(t, 'o', preview.frame.Rows[0][0].Rune)
288+
289+ stale := viewport
290+ stale.Status = protocol.PickerPreviewNoSuchTarget
291+ stale.Width, stale.Height, stale.Cells = 0, 0, nil
292+ require.True(t, preview.accept(stale, 7, "aa/first"))
293+ require.Empty(t, preview.frame.Rows, "a rejected row clears the displayed viewport")
294+
295+ preview.resetFor()
296+ require.True(t, preview.needsRequest(7, "aa/first"))
297+ require.Empty(t, preview.frame.Rows)
298+}
A internal/usecase/client/picker_lease.go +277 -0
@@ -0,0 +1,277 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/protocol"
4+
5+// This file owns the client-side presentation lease for one client-picker
6+// interaction. The daemon keeps mutation authority (it resolves opaque
7+// row keys and performs the handoff); the lease decides who writes the
8+// terminal: while it is acquiring, owned, or releasing the client
9+// consumes picker input and never lets a daemon paint overwrite the modal,
10+// yet every accepted daemon frame is still applied to the output shadow and
11+// acknowledged inside the bounded window.
12+//
13+// The lease is value-oriented and owns no locks: the attach loop is its
14+// only caller and already owns the terminal writer.
15+
16+// pickerLeaseState is the client-side presentation state of one
17+// client-picker interaction.
18+type pickerLeaseState uint8
19+
20+const (
21+ // pickerLeaseClosed displays daemon output normally.
22+ pickerLeaseClosed pickerLeaseState = iota
23+ // pickerLeaseAcquiring validated a snapshot whose acquisition barrier
24+ // (epoch, state) is not applied yet. Daemon frames up to that barrier
25+ // are still displayed; the picker frame is not.
26+ pickerLeaseAcquiring
27+ // pickerLeaseOwned displays the picker frame and consumes picker
28+ // input. Daemon frames are applied and acknowledged but never written.
29+ pickerLeaseOwned
30+ // pickerLeaseReleasing retired the interaction on PickerClose. The
31+ // picker frame stays until one authoritative full paint arrives; then
32+ // the client releases the terminal and resumes normal input.
33+ pickerLeaseReleasing
34+)
35+
36+// pickerLeaseEvent reports the presentation transition one accepted daemon
37+// frame triggers.
38+type pickerLeaseEvent uint8
39+
40+const (
41+ // pickerLeaseIdle displays the frame normally and leaves presentation
42+ // unchanged.
43+ pickerLeaseIdle pickerLeaseEvent = iota
44+ // pickerLeaseSuppress applies and acknowledges the frame without
45+ // writing it: the client owns the terminal.
46+ pickerLeaseSuppress
47+ // pickerLeaseAcquire displays the frame normally, then displays the
48+ // pending picker frame: the barrier is now applied.
49+ pickerLeaseAcquire
50+ // pickerLeaseRelease displays the authoritative full paint normally,
51+ // then clears the lease.
52+ pickerLeaseRelease
53+)
54+
55+// pickerLease is the client's presentation identity for one interaction:
56+// attachment UI generation, interaction ID, displayed revision, and the
57+// acquisition barrier the snapshot was built against. A generation change
58+// (reconnect) aborts the lease instead of carrying it into a new
59+// attachment.
60+type pickerLease struct {
61+ state pickerLeaseState
62+ generation uint64
63+ interaction uint64
64+ revision uint64
65+ barrierEpoch uint64
66+ barrierState uint64
67+ // pending holds the validated model until the barrier is applied; it
68+ // is nil in every other state.
69+ pending *pickerLoop
70+ // releaseAction is the ui-driver action waiting on the release paint
71+ // and releaseLocal reports whether it completes locally (a cancel
72+ // consumed by the client) instead of through the daemon handoff.
73+ releaseAction uint64
74+ releaseLocal bool
75+ // releaseEpoch/releaseState name the applied output boundary that was
76+ // current when the daemon's close for this interaction was observed.
77+ // Only a full paint accepted after that boundary releases the terminal:
78+ // a Full already in flight before the close is a suppressed artifact,
79+ // not the authoritative restore the release waits for.
80+ releaseEpoch uint64
81+ releaseState uint64
82+ // daemonClosed reports whether the serving daemon confirmed the close.
83+ // A client-side retire alone never releases: the daemon sends the close
84+ // before its restore paint, so waiting for it orders the release after
85+ // the close instead of after an in-flight paint.
86+ daemonClosed bool
87+}
88+
89+// active reports whether the lease owns picker presentation.
90+func (l *pickerLease) active() bool {
91+ return l != nil && l.state != pickerLeaseClosed
92+}
93+
94+// owns reports whether the client has taken the terminal over: only then is
95+// picker input applied to the displayed model.
96+func (l *pickerLease) owns() bool {
97+ return l != nil && l.state == pickerLeaseOwned
98+}
99+
100+// releasingFor reports whether this lease is retiring the named
101+// interaction, which is the only case where a close may extend its drain.
102+func (l *pickerLease) releasingFor(interaction uint64) bool {
103+ return l != nil && l.state == pickerLeaseReleasing && l.interaction == interaction
104+}
105+
106+// reset returns the lease to closed presentation.
107+func (l *pickerLease) reset() {
108+ *l = pickerLease{}
109+}
110+
111+// abort ends the lease without a release paint (disconnect, generation
112+// change, or a superseding interaction). It reports the pending local
113+// action that can no longer complete.
114+func (l *pickerLease) abort() (actionID uint64, local bool) {
115+ if l == nil {
116+ return 0, false
117+ }
118+ actionID, local = l.releaseAction, l.releaseLocal
119+ if actionID != 0 && !local {
120+ // A handoff-bound action stays unresolved: the daemon owns its
121+ // outcome and the caller reports the unknown outcome.
122+ actionID, local = 0, false
123+ }
124+ l.reset()
125+ return actionID, local
126+}
127+
128+// pickerBarrier is the daemon output boundary one offer names: the client
129+// must display admitted output through it before it takes the terminal.
130+type pickerBarrier struct {
131+ epoch uint64
132+ state uint64
133+}
134+
135+// admitSnapshot admits one validated snapshot against the client's applied
136+// output state. It reports ready=true when the barrier named by the offer is
137+// already applied and the caller must display the picker frame now.
138+// superseded is a copy of the previous lease when a new interaction replaces
139+// it, so the caller can resolve the old pending action without touching the
140+// newly installed lease.
141+//
142+// A snapshot for the current interaction only replaces the pending model
143+// with a newer source revision, and never after the release started: a
144+// superseding interaction is a new namespace whose close/full pair cannot
145+// release the previous one.
146+func (l *pickerLease) admitSnapshot(snapshot protocol.PickerSnapshot, barrier pickerBarrier, loop *pickerLoop, applied outputApplyState, generation uint64) (ready bool, superseded *pickerLease) {
147+ if l == nil || snapshot.InteractionID == 0 {
148+ return false, nil
149+ }
150+ if l.active() {
151+ if l.interaction == snapshot.InteractionID {
152+ if l.state == pickerLeaseReleasing || snapshot.SourceRevision <= l.revision {
153+ return false, nil
154+ }
155+ }
156+ }
157+ if l.active() && l.interaction != snapshot.InteractionID {
158+ previous := *l
159+ superseded = &previous
160+ }
161+ *l = pickerLease{
162+ state: pickerLeaseAcquiring, generation: generation, interaction: snapshot.InteractionID,
163+ revision: snapshot.SourceRevision, barrierEpoch: barrier.epoch, barrierState: barrier.state,
164+ pending: loop,
165+ }
166+ if barrierReached(applied, barrier.epoch, barrier.state) {
167+ // The barrier is already displayed: the client owns the terminal
168+ // from this moment, before any later daemon frame can be written.
169+ l.state = pickerLeaseOwned
170+ return true, superseded
171+ }
172+ return false, superseded
173+}
174+
175+// observe reports the presentation decision for one accepted daemon frame.
176+// Only accepted frames reach it, so the output dependency chain, reset
177+// handling, and bounded ACK window keep advancing in every state.
178+func (l *pickerLease) observe(output protocol.Output, applied outputApplyState) pickerLeaseEvent {
179+ if l == nil {
180+ return pickerLeaseIdle
181+ }
182+ switch l.state {
183+ case pickerLeaseAcquiring:
184+ if barrierReached(applied, l.barrierEpoch, l.barrierState) {
185+ l.state = pickerLeaseOwned
186+ return pickerLeaseAcquire
187+ }
188+ return pickerLeaseIdle
189+ case pickerLeaseOwned:
190+ return pickerLeaseSuppress
191+ case pickerLeaseReleasing:
192+ // Only an authoritative full paint accepted after the serving
193+ // daemon's close releases the terminal: an incremental delta, a Full
194+ // already in flight before the close, or any paint seen before the
195+ // close was confirmed may itself be a suppressed artifact.
196+ if l.daemonClosed && output.Full && stateAfter(applied, l.releaseEpoch, l.releaseState) {
197+ return pickerLeaseRelease
198+ }
199+ return pickerLeaseSuppress
200+ }
201+ return pickerLeaseIdle
202+}
203+
204+// stateAfter reports whether the applied output state is strictly newer
205+// than the named (epoch, state) boundary. An uninitialized state has
206+// applied only the implicit empty stream: epoch 1, state 0.
207+func stateAfter(applied outputApplyState, epoch, state uint64) bool {
208+ appliedEpoch, appliedState := applied.epoch, applied.state
209+ if !applied.initialized {
210+ appliedEpoch, appliedState = 1, 0
211+ }
212+ if appliedEpoch != epoch {
213+ return appliedEpoch > epoch
214+ }
215+ return appliedState > state
216+}
217+
218+// beginRelease retires the interaction named by one close. It reports
219+// false for a duplicate or foreign close, which must not restart or extend
220+// the release. fromDaemon marks the serving daemon's own close, which is
221+// the ordering barrier for the release: a client-side retire waits for it
222+// before any paint can release the terminal. The pending action completes
223+// after the release paint: local for a cancel the client consumed, through
224+// the daemon handoff otherwise.
225+func (l *pickerLease) beginRelease(interaction, actionID uint64, local, fromDaemon bool, applied outputApplyState) bool {
226+ if l == nil || !l.active() || l.state == pickerLeaseReleasing || l.interaction != interaction {
227+ return false
228+ }
229+ l.state = pickerLeaseReleasing
230+ l.pending = nil
231+ l.releaseAction, l.releaseLocal = actionID, local
232+ l.releaseEpoch, l.releaseState = applied.epoch, applied.state
233+ if !applied.initialized {
234+ l.releaseEpoch, l.releaseState = 1, 0
235+ }
236+ l.daemonClosed = fromDaemon
237+ return true
238+}
239+
240+// confirmDaemonClose records the serving daemon's close for the releasing
241+// interaction and re-captures the release boundary at that point. Only
242+// paints accepted after it can release the terminal.
243+func (l *pickerLease) confirmDaemonClose(interaction uint64, applied outputApplyState) bool {
244+ if l == nil || l.state != pickerLeaseReleasing || l.interaction != interaction {
245+ return false
246+ }
247+ l.daemonClosed = true
248+ l.releaseEpoch, l.releaseState = applied.epoch, applied.state
249+ if !applied.initialized {
250+ l.releaseEpoch, l.releaseState = 1, 0
251+ }
252+ return true
253+}
254+
255+// finishRelease clears the lease after the release paint was displayed and
256+// published. It reports the pending local action to complete.
257+func (l *pickerLease) finishRelease() (actionID uint64, local bool) {
258+ if l == nil {
259+ return 0, false
260+ }
261+ actionID, local = l.releaseAction, l.releaseLocal
262+ l.reset()
263+ return actionID, local
264+}
265+
266+// barrierReached reports whether the applied output state covers the
267+// acquisition barrier (epoch, state). An uninitialized state has applied
268+// only the implicit empty stream: epoch 1, state 0.
269+func barrierReached(applied outputApplyState, epoch, state uint64) bool {
270+ if !applied.initialized {
271+ return epoch <= 1 && state == 0
272+ }
273+ if applied.epoch != epoch {
274+ return applied.epoch > epoch
275+ }
276+ return applied.state >= state
277+}
A internal/usecase/client/picker_lease_test.go +222 -0
@@ -0,0 +1,222 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// pickerLeaseTestState builds an applied output state for barrier checks.
11+func pickerLeaseTestState(epoch, state uint64) outputApplyState {
12+ return outputApplyState{epoch: epoch, state: state, initialized: true}
13+}
14+
15+// pickerLeaseTestSnapshot builds one validated source publication for the
16+// lease. The acquisition barrier travels in the offer, so it is passed
17+// separately to admitSnapshot.
18+func pickerLeaseTestSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
19+ return protocol.PickerSnapshot{
20+ InteractionID: interaction, SourceID: "serving", SourceRevision: revision,
21+ Status: protocol.PickerSourceOK,
22+ Lines: []protocol.PickerLine{
23+ {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanNavigate},
24+ },
25+ Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
26+ }
27+}
28+
29+func pickerLeaseTestLoop() *pickerLoop {
30+ return &pickerLoop{interaction: 1}
31+}
32+
33+func TestPickerLeaseAcquiresOnlyAtTheBarrier(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ applied outputApplyState
37+ barrierEpoch uint64
38+ barrierState uint64
39+ ready bool
40+ owned bool
41+ }{
42+ {name: "uninitialized zero barrier is already applied", applied: outputApplyState{}, barrierEpoch: 1, ready: true, owned: true},
43+ {name: "applied state equals the barrier", applied: pickerLeaseTestState(4, 3), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
44+ {name: "applied state passed the barrier", applied: pickerLeaseTestState(4, 5), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
45+ {name: "newer epoch passed the barrier", applied: pickerLeaseTestState(6, 1), barrierEpoch: 4, barrierState: 3, ready: true, owned: true},
46+ {name: "barrier still ahead", applied: pickerLeaseTestState(4, 1), barrierEpoch: 4, barrierState: 3, ready: false, owned: false},
47+ {name: "barrier in a future epoch", applied: pickerLeaseTestState(4, 9), barrierEpoch: 5, barrierState: 1, ready: false, owned: false},
48+ }
49+ for _, test := range tests {
50+ t.Run(test.name, func(t *testing.T) {
51+ var lease pickerLease
52+ ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: test.barrierEpoch, state: test.barrierState}, pickerLeaseTestLoop(), test.applied, 3)
53+ require.Nil(t, superseded)
54+ require.Equal(t, test.ready, ready)
55+ require.Equal(t, test.owned, lease.state == pickerLeaseOwned)
56+ require.Equal(t, uint64(7), lease.interaction)
57+ require.Equal(t, uint64(3), lease.generation)
58+ })
59+ }
60+}
61+
62+func TestPickerLeaseAcquiresWhenAHeldBarrierArrives(t *testing.T) {
63+ var lease pickerLease
64+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
65+ require.False(t, ready)
66+ require.Equal(t, pickerLeaseAcquiring, lease.state)
67+
68+ // A still-short frame keeps the lease acquiring and displays the frame.
69+ event := lease.observe(protocol.Output{Epoch: 4, New: 2, Full: true}, pickerLeaseTestState(4, 1))
70+ require.Equal(t, pickerLeaseIdle, event)
71+ require.Equal(t, pickerLeaseAcquiring, lease.state)
72+
73+ // The exact barrier is applied: the frame is displayed first and the
74+ // picker follows it. The pending model stays for the caller to install.
75+ event = lease.observe(protocol.Output{Epoch: 4, New: 3, Base: 2}, pickerLeaseTestState(4, 3))
76+ require.Equal(t, pickerLeaseAcquire, event)
77+ require.Equal(t, pickerLeaseOwned, lease.state)
78+ require.NotNil(t, lease.pending)
79+ require.Equal(t, uint64(1), lease.pending.interaction)
80+}
81+
82+func TestPickerLeaseSuppressesOwnedOutputAndPublishesBoundary(t *testing.T) {
83+ var lease pickerLease
84+ require.True(t, func() bool {
85+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
86+ return ready
87+ }())
88+
89+ require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
90+ require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
91+ require.Equal(t, pickerLeaseOwned, lease.state)
92+}
93+
94+func TestPickerLeaseReleasesOnlyAfterTheDaemonCloseAndANewerFullPaint(t *testing.T) {
95+ var lease pickerLease
96+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 2)
97+ require.True(t, ready)
98+
99+ // The client cancels: the client-side retire alone never releases, because
100+ // it cannot tell a post-close restore from a paint already in flight.
101+ require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
102+ require.Equal(t, pickerLeaseReleasing, lease.state)
103+ require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 4, New: 2, Base: 1}, pickerLeaseTestState(4, 2)))
104+ require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
105+ "a Full in flight before the daemon close must not release")
106+
107+ // The daemon confirms the close at the applied boundary it was rendering:
108+ // only paints accepted after that boundary release the terminal.
109+ require.True(t, lease.confirmDaemonClose(7, pickerLeaseTestState(5, 1)))
110+ require.False(t, lease.confirmDaemonClose(8, pickerLeaseTestState(5, 1)), "a foreign confirmation must not move the boundary")
111+ require.Equal(t, pickerLeaseSuppress, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)),
112+ "a paint at the close boundary is not newer than it")
113+ require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 2, Full: true}, pickerLeaseTestState(5, 2)))
114+
115+ actionID, local := lease.finishRelease()
116+ require.Equal(t, uint64(42), actionID)
117+ require.True(t, local)
118+ require.False(t, lease.active())
119+}
120+
121+func TestPickerLeaseDaemonCloseReleasesWithoutAClientRetire(t *testing.T) {
122+ var lease pickerLease
123+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
124+ require.True(t, ready)
125+
126+ // A daemon-initiated close (a resolved selection) carries its own barrier.
127+ require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 1)))
128+ require.Equal(t, pickerLeaseRelease, lease.observe(protocol.Output{Epoch: 5, New: 1, Full: true}, pickerLeaseTestState(5, 1)))
129+}
130+
131+func TestPickerLeaseForeignAndDuplicateCloseNeverRelease(t *testing.T) {
132+ var lease pickerLease
133+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
134+ require.True(t, ready)
135+
136+ require.False(t, lease.beginRelease(6, 42, true, false, pickerLeaseTestState(4, 1)), "foreign interaction must not release")
137+ require.False(t, lease.beginRelease(0, 42, true, false, pickerLeaseTestState(4, 1)), "zero interaction must not release")
138+ require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
139+ require.False(t, lease.beginRelease(7, 43, true, false, pickerLeaseTestState(4, 1)), "duplicate close must not restart the release")
140+ require.Equal(t, uint64(42), lease.releaseAction)
141+}
142+
143+func TestPickerLeaseSupersedingSnapshotStartsANewNamespace(t *testing.T) {
144+ var lease pickerLease
145+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
146+ require.True(t, ready)
147+ require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
148+
149+ // A superseding interaction cannot be released by the prior close.
150+ ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(8, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
151+ require.True(t, ready)
152+ require.NotNil(t, superseded)
153+ require.Equal(t, uint64(7), superseded.interaction)
154+ require.Equal(t, uint64(8), lease.interaction)
155+ require.Equal(t, pickerLeaseOwned, lease.state)
156+ require.False(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
157+}
158+
159+func TestPickerLeaseSameInteractionRevisionRules(t *testing.T) {
160+ var lease pickerLease
161+ ready, _ := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
162+ require.True(t, ready)
163+
164+ // Duplicate and older revisions never replace the displayed model.
165+ ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(7, 3), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
166+ require.False(t, ready)
167+ require.Nil(t, superseded)
168+ ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 2), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
169+ require.False(t, ready)
170+ require.Nil(t, superseded)
171+
172+ // A newer revision refreshes in place.
173+ ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 4), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
174+ require.False(t, ready)
175+ require.Nil(t, superseded)
176+ require.Equal(t, uint64(4), lease.revision)
177+
178+ // A newer revision that also moved the barrier to an applied state
179+ // acquires immediately.
180+ ready, _ = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 5), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
181+ require.True(t, ready)
182+
183+ // A release in progress refuses revisions: the close/full pair cannot be
184+ // replaced by a late refresh.
185+ require.True(t, lease.beginRelease(7, 0, false, true, pickerLeaseTestState(4, 2)))
186+ ready, superseded = lease.admitSnapshot(pickerLeaseTestSnapshot(7, 6), pickerBarrier{epoch: 4, state: 2}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 2), 1)
187+ require.False(t, ready)
188+ require.Nil(t, superseded)
189+}
190+
191+func TestPickerLeaseAbortReportsPendingLocalAction(t *testing.T) {
192+ var lease pickerLease
193+ lease.admitSnapshot(pickerLeaseTestSnapshot(7, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
194+ require.True(t, lease.beginRelease(7, 42, true, false, pickerLeaseTestState(4, 1)))
195+
196+ actionID, local := lease.abort()
197+ require.Equal(t, uint64(42), actionID)
198+ require.True(t, local)
199+ require.False(t, lease.active())
200+
201+ // A handoff-bound action stays unresolved: the daemon owns its outcome.
202+ lease.admitSnapshot(pickerLeaseTestSnapshot(9, 1), pickerBarrier{epoch: 4, state: 1}, pickerLeaseTestLoop(), pickerLeaseTestState(4, 1), 1)
203+ lease.beginRelease(9, 77, false, false, pickerLeaseTestState(4, 1))
204+ actionID, local = lease.abort()
205+ require.Zero(t, actionID)
206+ require.False(t, local)
207+}
208+
209+func TestPickerLeaseRejectsUnusableSnapshots(t *testing.T) {
210+ var lease pickerLease
211+ ready, superseded := lease.admitSnapshot(pickerLeaseTestSnapshot(0, 1), pickerBarrier{epoch: 1, state: 0}, pickerLeaseTestLoop(), outputApplyState{}, 1)
212+ require.False(t, ready)
213+ require.Nil(t, superseded)
214+ require.False(t, lease.active())
215+}
216+
217+func TestPickerLeaseClosedPresentationNeverSuppresses(t *testing.T) {
218+ var lease pickerLease
219+ require.False(t, lease.active())
220+ require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 1, Full: true}, pickerLeaseTestState(1, 1)))
221+ require.Equal(t, pickerLeaseIdle, lease.observe(protocol.Output{Epoch: 1, New: 2, Base: 1}, pickerLeaseTestState(1, 2)))
222+}
A internal/usecase/client/picker_retire_test.go +155 -0
@@ -0,0 +1,155 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// pickerReleasePaint sends the daemon's authoritative repaint that releases
17+// the client's presentation lease.
18+func pickerReleasePaint(transport *attachPaletteTransport, text string) []byte {
19+ view := protocol.ViewContext{
20+ Publication: 2,
21+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
22+ LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture",
23+ }},
24+ TabID: "t_abc123", FocusedPaneID: "p_def456",
25+ }
26+ payload, err := wire.MarshalOutput(protocol.Output{
27+ Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24},
28+ Context: &view, Data: []byte("\x1b[2J\x1b[H" + text),
29+ })
30+ if err != nil {
31+ return nil
32+ }
33+ transport.detached <- wire.Frame{Type: wire.MsgOutput, Payload: payload}
34+ return payload
35+}
36+
37+// TestPickerRetireRejectsLateSnapshot pins that a closed interaction is
38+// never reopened by an in-flight snapshot for the same ID.
39+func TestPickerRetireRejectsLateSnapshot(t *testing.T) {
40+ ctx := context.Background()
41+ harness := startPickerE2E(t)
42+ ui, transport := harness.ui, harness.transport
43+
44+ snapshot := openPickerOnHarness(t, transport)
45+ attached := ports.UIStatusAttached
46+ first := "first"
47+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
48+ require.NoError(t, err)
49+
50+ // The daemon retires the interaction and repaints authoritatively.
51+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})}
52+ require.NotNil(t, pickerReleasePaint(transport, "released"))
53+ released := "released"
54+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &released}})
55+ require.NoError(t, err)
56+
57+ // A late snapshot for the retired interaction must not restore the modal.
58+ late := snapshot
59+ late.SourceRevision = snapshot.SourceRevision + 1
60+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(late)}
61+ time.Sleep(100 * time.Millisecond)
62+ captured, err := ui.Capture(ui.Handle())
63+ require.NoError(t, err)
64+ text := uiSnapshotText(captured)
65+ require.Contains(t, text, "released")
66+ require.NotContains(t, text, "second", "a retired interaction must not be reopened")
67+}
68+
69+// TestPickerReplaceAbortsSupersededLease pins that a superseding
70+// interaction takes presentation over and commits under its own ID, and
71+// that the replaced lease is resolved rather than left installed.
72+func TestPickerReplaceAbortsSupersededLease(t *testing.T) {
73+ ctx := context.Background()
74+ harness := startPickerE2E(t)
75+ ui, transport := harness.ui, harness.transport
76+
77+ first := openPickerOnHarness(t, transport)
78+ attached := ports.UIStatusAttached
79+ firstText := "first"
80+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &firstText}})
81+ require.NoError(t, err)
82+
83+ // A new interaction arrives without a close for the previous one.
84+ superseding := protocol.PickerSnapshot{
85+ InteractionID: first.InteractionID + 1, SourceID: "serving", SourceRevision: 1, Status: protocol.PickerSourceOK,
86+ Lines: []protocol.PickerLine{
87+ {Key: "cc/third", Kind: protocol.PickerLineSession, Label: "third", Focusable: true, Actions: protocol.PickerCanNavigate},
88+ },
89+ Cursor: protocol.PickerCursor{Key: "cc/third", Index: 0},
90+ }
91+ transport.detached <- wire.Frame{Type: wire.MsgPickerSnapshot, Payload: wire.MarshalPickerSnapshot(superseding)}
92+ third := "third"
93+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &third}})
94+ require.NoError(t, err)
95+
96+ // A commit crosses under the superseding interaction, never the retired one.
97+ committed := make(chan ports.UIActionResult, 1)
98+ go func() {
99+ action, actionErr := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}})
100+ if actionErr == nil {
101+ committed <- action
102+ }
103+ }()
104+ selection := awaitPickerSelection(t, transport)
105+ require.Equal(t, superseding.InteractionID, selection.InteractionID)
106+ require.Equal(t, "cc/third", selection.Key)
107+}
108+
109+// TestPickerStaleRevisionClosesInteraction pins that a terminal rejection
110+// closes the daemon-side interaction instead of leaving it open, and that
111+// input routing resumes only after the authoritative repaint.
112+func TestPickerStaleRevisionClosesInteraction(t *testing.T) {
113+ ctx := context.Background()
114+ reader, writer := io.Pipe()
115+ t.Cleanup(func() { _ = writer.Close() })
116+ harness := startPickerE2EWithInput(t, reader)
117+ ui, transport := harness.ui, harness.transport
118+
119+ snapshot := openPickerOnHarness(t, transport)
120+ attached := ports.UIStatusAttached
121+ first := "first"
122+ _, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &first}})
123+ require.NoError(t, err)
124+
125+ // The daemon refuses the committed revision: the client must close the
126+ // interaction so it stops owning input on the daemon side.
127+ failure := wire.MarshalPickerFailure(protocol.PickerFailure{
128+ CauseActionID: 3, InteractionID: snapshot.InteractionID, SourceID: "serving",
129+ Key: "aa/first", Action: protocol.PickerActionNavigate, Code: protocol.PickerStaleRevision,
130+ })
131+ require.NotNil(t, failure)
132+ transport.detached <- wire.Frame{Type: wire.MsgPickerFailure, Payload: failure}
133+
134+ closePayload := awaitWireFrame(t, transport, wire.MsgPickerCloseClient)
135+ closed, err := wire.UnmarshalPickerClose(closePayload)
136+ require.NoError(t, err)
137+ require.Equal(t, snapshot.InteractionID, closed.InteractionID)
138+
139+ // Input during the release window is dropped, then routing resumes once
140+ // the daemon confirms the close and its authoritative repaint is shown.
141+ writeTerminal(t, writer, "leaked")
142+ requireNoPickerInput(t, transport)
143+ transport.detached <- wire.Frame{Type: wire.MsgPickerClosedServer, Payload: wire.MarshalPickerClosed(protocol.PickerClosed{InteractionID: snapshot.InteractionID, BarrierEpoch: 2, BarrierState: 1})}
144+ require.NotNil(t, pickerReleasePaint(transport, "released"))
145+ released := "released"
146+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached, TextContains: &released}})
147+ require.NoError(t, err, "the release paint must be displayed")
148+
149+ writeTerminal(t, writer, "ok")
150+ select {
151+ case <-transport.inputCh:
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("session input did not resume after the release paint")
154+ }
155+}
M internal/usecase/client/remote_attach_validation_test.go +1 -1
@@ -18,7 +18,7 @@ func TestMalformedAttachRequestsNeverDial(t *testing.T) {
18 {name: "missing session name", request: AttachRequest{Intent: protocol.IntentAttach}},18 {name: "missing session name", request: AttachRequest{Intent: protocol.IntentAttach}},
19 {name: "unsafe session name", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "bad name"}},19 {name: "unsafe session name", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "bad name"}},
20 {name: "daemon-owned without remote", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}},20 {name: "daemon-owned without remote", request: AttachRequest{Intent: protocol.IntentAttach, SessionName: "main", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}},
21- {name: "ephemeral with navigation capability", request: AttachRequest{Intent: protocol.IntentEphemeral, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}},21+ {name: "ephemeral with navigation capability", request: AttachRequest{Intent: protocol.IntentEphemeral, NavigationCapabilities: protocol.NavigationCapabilityInventory}},
22 }22 }
23 for _, tt := range tests {23 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {24 t.Run(tt.name, func(t *testing.T) {
A internal/usecase/client/route_observers.go +67 -0
@@ -0,0 +1,67 @@
1+package client
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+type routeObserverKey struct {
11+ origin protocol.RouteOrigin
12+ originKey string
13+}
14+
15+type routeObserverAuthority struct {
16+ key routeObserverKey
17+ epoch uint64
18+ dialer ports.ClientDialer
19+}
20+
21+type routeObserverRegistry struct {
22+ mu sync.RWMutex
23+ nextEpoch uint64
24+ authorities map[routeObserverKey]routeObserverAuthority
25+}
26+
27+func newRouteObserverRegistry() *routeObserverRegistry {
28+ return &routeObserverRegistry{authorities: make(map[routeObserverKey]routeObserverAuthority)}
29+}
30+
31+func (r *routeObserverRegistry) register(request AttachRequest, dialer ports.ClientDialer) {
32+ if r == nil || dialer == nil {
33+ return
34+ }
35+ origin := normalizeRouteOrigin(request.Origin, request.Remote)
36+ key := routeObserverKey{origin: origin, originKey: normalizeRouteOriginKey(request.OriginKey, origin)}
37+ r.mu.Lock()
38+ defer r.mu.Unlock()
39+ if current, ok := r.authorities[key]; ok && current.dialer == dialer {
40+ return
41+ }
42+ r.nextEpoch++
43+ r.authorities[key] = routeObserverAuthority{key: key, epoch: r.nextEpoch, dialer: dialer}
44+}
45+
46+func (r *routeObserverRegistry) snapshot() []routeObserverAuthority {
47+ if r == nil {
48+ return nil
49+ }
50+ r.mu.RLock()
51+ defer r.mu.RUnlock()
52+ authorities := make([]routeObserverAuthority, 0, len(r.authorities))
53+ for _, authority := range r.authorities {
54+ authorities = append(authorities, authority)
55+ }
56+ return authorities
57+}
58+
59+func (r *routeObserverRegistry) current(authority routeObserverAuthority) bool {
60+ if r == nil {
61+ return false
62+ }
63+ r.mu.RLock()
64+ defer r.mu.RUnlock()
65+ current, ok := r.authorities[authority.key]
66+ return ok && current.epoch == authority.epoch && current.dialer == authority.dialer
67+}
M internal/usecase/client/routes.go +51 -2
@@ -569,6 +569,57 @@ func (r routeRecord) snapshotEntry() protocol.RecentRouteEntry {
569 }569 }
570 }570 }
571 571
572+func (l *routeLedger) observationTargets(origin protocol.RouteOrigin, originKey string) []protocol.ExactSessionTarget {
573+ return l.observationTargetsExcept(origin, originKey, nil)
574+}
575+
576+func (l *routeLedger) observationTargetsExcept(origin protocol.RouteOrigin, originKey string, excluded *protocol.ExactSessionTarget) []protocol.ExactSessionTarget {
577+ if l == nil {
578+ return nil
579+ }
580+ l.mu.RLock()
581+ defer l.mu.RUnlock()
582+ targets := make([]protocol.ExactSessionTarget, 0, len(l.entries))
583+ for _, entry := range l.entries {
584+ if entry.origin == origin && entry.originKey == originKey && (excluded == nil || entry.target != *excluded) {
585+ targets = append(targets, entry.target)
586+ }
587+ }
588+ return targets
589+}
590+
591+func (l *routeLedger) applyObservations(origin protocol.RouteOrigin, originKey string, observations []protocol.PickerRouteObservation) bool {
592+ if l == nil {
593+ return false
594+ }
595+ l.mu.Lock()
596+ defer l.mu.Unlock()
597+ byTarget := make(map[protocol.ExactSessionTarget]protocol.PickerRouteObservation, len(observations))
598+ for _, observation := range observations {
599+ byTarget[observation.Target] = observation
600+ }
601+ changed := false
602+ for i := range l.entries {
603+ entry := &l.entries[i]
604+ if entry.origin != origin || entry.originKey != originKey {
605+ continue
606+ }
607+ observation, ok := byTarget[entry.target]
608+ if !ok || observation.Presence == protocol.PickerRouteUnknown {
609+ continue
610+ }
611+ attention := observation.Presence == protocol.PickerRoutePresent && observation.Attention
612+ if entry.presentation.attention != attention {
613+ entry.presentation.attention = attention
614+ changed = true
615+ }
616+ }
617+ if changed {
618+ l.generation++
619+ }
620+ return changed
621+}
622+
572 func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot {623 func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot {
573 l.mu.RLock()624 l.mu.RLock()
574 defer l.mu.RUnlock()625 defer l.mu.RUnlock()
@@ -622,7 +673,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
622 request.RemoteTarget = nil673 request.RemoteTarget = nil
623 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {674 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
624 request.NavigationCapabilities = 0675 request.NavigationCapabilities = 0
625- request.StartupOverlay = protocol.StartupOverlayNone
626 }676 }
627 request.EnvironmentPolicy = environmentPolicy677 request.EnvironmentPolicy = environmentPolicy
628 request.ExactTarget = target.ExactTarget678 request.ExactTarget = target.ExactTarget
@@ -640,7 +690,6 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta
640 request.RemoteTarget = nil690 request.RemoteTarget = nil
641 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {691 if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned {
642 request.NavigationCapabilities = 0692 request.NavigationCapabilities = 0
643- request.StartupOverlay = protocol.StartupOverlayNone
644 }693 }
645 request.EnvironmentPolicy = environmentPolicy694 request.EnvironmentPolicy = environmentPolicy
646 return request695 return request
M internal/usecase/client/routes_test.go +57 -5
@@ -218,8 +218,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
218 active := routeTestCandidate(2, protocol.RouteOriginLocal)218 active := routeTestCandidate(2, protocol.RouteOriginLocal)
219 active.originKey = "local"219 active.originKey = "local"
220 active.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned220 active.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
221- active.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker221+ active.request.NavigationCapabilities = protocol.NavigationCapabilityInventory
222- active.request.StartupOverlay = protocol.StartupOverlaySessionPicker
223 222
224 for _, tt := range []struct {223 for _, tt := range []struct {
225 name string224 name string
@@ -235,8 +234,7 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
235 stored := routeTestCandidate(1, protocol.RouteOriginLocal)234 stored := routeTestCandidate(1, protocol.RouteOriginLocal)
236 stored.originKey = "local"235 stored.originKey = "local"
237 stored.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned236 stored.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
238- stored.request.NavigationCapabilities = protocol.NavigationCapabilityHomePicker237+ stored.request.NavigationCapabilities = protocol.NavigationCapabilityInventory
239- stored.request.StartupOverlay = protocol.StartupOverlaySessionPicker
240 _, err := ledger.commit(stored)238 _, err := ledger.commit(stored)
241 require.NoError(t, err)239 require.NoError(t, err)
242 }240 }
@@ -248,7 +246,6 @@ func TestRouteLedgerSamePeerHandoffClearsDaemonOwnedNavigationForLocalRoutes(t *
248 246
249 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)247 require.Equal(t, protocol.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
250 require.Zero(t, request.NavigationCapabilities)248 require.Zero(t, request.NavigationCapabilities)
251- require.Equal(t, protocol.StartupOverlayNone, request.StartupOverlay)
252 require.NoError(t, validateAttachRequest(request))249 require.NoError(t, validateAttachRequest(request))
253 })250 })
254 }251 }
@@ -287,6 +284,61 @@ func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
287 require.Nil(t, request.RemoteTarget)284 require.Nil(t, request.RemoteTarget)
288 }285 }
289 286
287+// TestRouteLedgerSamePeerHandoffPreservesRemoteDaemonOwnedPolicy pins the
288+// hybrid same-peer pairing: on a remote origin the handoff keeps the
289+// serving daemon's policy, the explicit tab, and the request environment.
290+// Local-origin downgrade rules must not leak into the remote path.
291+func TestRouteLedgerSamePeerHandoffPreservesRemoteDaemonOwnedPolicy(t *testing.T) {
292+ ledger := newRouteLedger()
293+ work := routeTestCandidate(1, protocol.RouteOriginRemote)
294+ work.originKey = "remote"
295+ work.request.Remote = true
296+ work.request.OriginKey = "remote"
297+ work.request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned
298+ work.request.Environment = []string{"REMOTE=1"}
299+ work.request.PreferredTabID = "remembered-tab"
300+ _, err := ledger.commit(work)
301+ require.NoError(t, err)
302+
303+ target := routeTestTarget(1)
304+ request := ledger.samePeerHandoff(work.request, protocol.AttachTarget{
305+ Session: work.target.SessionName,
306+ Intent: protocol.IntentAttach,
307+ ExactTarget: &target,
308+ PreferredTabID: "chosen-tab",
309+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
310+ })
311+
312+ require.True(t, request.Remote)
313+ require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy)
314+ require.Equal(t, domain.TabStableID("chosen-tab"), request.PreferredTabID)
315+ require.Equal(t, []string{"REMOTE=1"}, request.Environment)
316+ require.NoError(t, validateAttachRequest(request))
317+}
318+
319+func TestRouteLedgerAppliesExactOriginObservationsWithoutReordering(t *testing.T) {
320+ ledger := newRouteLedger()
321+ local := routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "local", Origin: protocol.RouteOriginLocal, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}, protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}}, nil, 0)
322+ _, err := ledger.commitAttach(local)
323+ require.NoError(t, err)
324+ remote := routeCandidateForAttach(AttachRequest{Intent: protocol.IntentAttach, SessionName: "work", Origin: protocol.RouteOriginRemote, OriginKey: "remote", Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work"}}, nil, 0)
325+ _, err = ledger.commitAttach(remote)
326+ require.NoError(t, err)
327+ before := ledger.snapshot()
328+
329+ changed := ledger.applyObservations(protocol.RouteOriginLocal, normalizeRouteOriginKey("", protocol.RouteOriginLocal), []protocol.PickerRouteObservation{{
330+ Target: local.target, Presence: protocol.PickerRoutePresent, Attention: true,
331+ }})
332+
333+ require.True(t, changed)
334+ after := ledger.snapshot()
335+ require.Equal(t, before.Active, after.Active)
336+ require.Equal(t, before.Previous, after.Previous)
337+ require.Equal(t, []string{"local"}, []string{after.Entries[0].Name})
338+ require.True(t, after.Entries[0].Attention)
339+ require.False(t, after.ActiveEntry.Attention)
340+}
341+
290 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {342 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
291 ledger := newRouteLedger()343 ledger := newRouteLedger()
292 first := routeTestCandidate(0, protocol.RouteOriginRemote)344 first := routeTestCandidate(0, protocol.RouteOriginRemote)
M internal/usecase/client/typed_connection_external_test.go +14 -6
@@ -132,8 +132,6 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
132 case protocol.SamePeerSwitchRequest:132 case protocol.SamePeerSwitchRequest:
133 p, e := wire.MarshalSamePeerSwitchRequest(x)133 p, e := wire.MarshalSamePeerSwitchRequest(x)
134 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e134 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e
135- case protocol.ParkedRouteRequest:
136- return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(x)}, nil
137 case protocol.RouteNavigationFailure:135 case protocol.RouteNavigationFailure:
138 p, e := wire.MarshalRouteNavigationFailure(x)136 p, e := wire.MarshalRouteNavigationFailure(x)
139 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e137 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: p}, e
@@ -144,6 +142,10 @@ func externalClientFrame(m protocol.ClientMessage) (wire.Frame, error) {
144 return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil142 return wire.Frame{Type: wire.MsgNavigationInventoryPublication, Payload: wire.MarshalNavigationInventoryPublication(x)}, nil
145 case protocol.NavigationInventoryFailure:143 case protocol.NavigationInventoryFailure:
146 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: wire.MarshalNavigationInventoryFailure(x)}, nil144 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: wire.MarshalNavigationInventoryFailure(x)}, nil
145+ case protocol.PickerSelection:
146+ return wire.Frame{Type: wire.MsgPickerSelection, Payload: wire.MarshalPickerSelection(x)}, nil
147+ case protocol.PickerClose:
148+ return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: wire.MarshalPickerClose(x)}, nil
147 default:149 default:
148 return wire.Frame{}, errors.New("test client: unsupported message")150 return wire.Frame{}, errors.New("test client: unsupported message")
149 }151 }
@@ -166,10 +168,6 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
166 return wire.UnmarshalPong(f.Payload)168 return wire.UnmarshalPong(f.Payload)
167 case wire.MsgAttachTarget:169 case wire.MsgAttachTarget:
168 return wire.UnmarshalAttachTarget(f.Payload)170 return wire.UnmarshalAttachTarget(f.Payload)
169- case wire.MsgNavigationAction:
170- return wire.UnmarshalNavigationDirective(f.Payload)
171- case wire.MsgParkedRouteResponse:
172- return wire.UnmarshalParkedRouteResponse(f.Payload)
173 case wire.MsgNavigateRecentRoute:171 case wire.MsgNavigateRecentRoute:
174 return wire.UnmarshalRouteNavigationAction(f.Payload)172 return wire.UnmarshalRouteNavigationAction(f.Payload)
175 case wire.MsgRouteCreateSession:173 case wire.MsgRouteCreateSession:
@@ -184,6 +182,16 @@ func externalServerMessage(f wire.Frame) (protocol.ServerMessage, error) {
184 return wire.UnmarshalRoutePosition(f.Payload)182 return wire.UnmarshalRoutePosition(f.Payload)
185 case wire.MsgRouteNavigationFailure:183 case wire.MsgRouteNavigationFailure:
186 return wire.UnmarshalRouteNavigationFailure(f.Payload)184 return wire.UnmarshalRouteNavigationFailure(f.Payload)
185+ case wire.MsgPickerOffer:
186+ return wire.UnmarshalPickerOffer(f.Payload)
187+ case wire.MsgPickerSnapshot:
188+ return wire.UnmarshalPickerSnapshot(f.Payload)
189+ case wire.MsgPickerClosedServer:
190+ return wire.UnmarshalPickerClosed(f.Payload)
191+ case wire.MsgPickerResult:
192+ return wire.UnmarshalPickerResult(f.Payload)
193+ case wire.MsgPickerFailure:
194+ return wire.UnmarshalPickerFailure(f.Payload)
187 default:195 default:
188 return nil, errors.New("test client: unsupported server frame")196 return nil, errors.New("test client: unsupported server frame")
189 }197 }
M internal/usecase/client/typed_connection_test.go +30 -6
@@ -128,8 +128,6 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) {
128 case protocol.SamePeerSwitchRequest:128 case protocol.SamePeerSwitchRequest:
129 p, e := wire.MarshalSamePeerSwitchRequest(m)129 p, e := wire.MarshalSamePeerSwitchRequest(m)
130 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e130 return wire.Frame{Type: wire.MsgSamePeerSwitchRequest, Payload: p}, e
131- case protocol.ParkedRouteRequest:
132- return wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(m)}, nil
133 case protocol.RecentRouteSnapshot:131 case protocol.RecentRouteSnapshot:
134 p, e := wire.MarshalRecentRouteSnapshot(m)132 p, e := wire.MarshalRecentRouteSnapshot(m)
135 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: p}, e133 return wire.Frame{Type: wire.MsgRecentRouteSnapshot, Payload: p}, e
@@ -154,6 +152,24 @@ func testClientFrame(message protocol.ClientMessage) (wire.Frame, error) {
154 return wire.Frame{}, errors.New("test client connection: invalid inventory failure")152 return wire.Frame{}, errors.New("test client connection: invalid inventory failure")
155 }153 }
156 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: p}, nil154 return wire.Frame{Type: wire.MsgNavigationInventoryFailure, Payload: p}, nil
155+ case protocol.PickerSelection:
156+ p := wire.MarshalPickerSelection(m)
157+ if p == nil {
158+ return wire.Frame{}, errors.New("test client connection: invalid picker selection")
159+ }
160+ return wire.Frame{Type: wire.MsgPickerSelection, Payload: p}, nil
161+ case protocol.PickerClose:
162+ p := wire.MarshalPickerClose(m)
163+ if p == nil {
164+ return wire.Frame{}, errors.New("test client connection: invalid picker close")
165+ }
166+ return wire.Frame{Type: wire.MsgPickerCloseClient, Payload: p}, nil
167+ case protocol.PickerPreviewRequest:
168+ p := wire.MarshalPickerPreviewRequest(m)
169+ if p == nil {
170+ return wire.Frame{}, errors.New("test client connection: invalid picker preview request")
171+ }
172+ return wire.Frame{Type: wire.MsgPickerPreviewRequest, Payload: p}, nil
157 default:173 default:
158 return wire.Frame{}, errors.New("test client connection: unsupported client message")174 return wire.Frame{}, errors.New("test client connection: unsupported client message")
159 }175 }
@@ -175,8 +191,6 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
175 return wire.UnmarshalSessions(frame.Payload)191 return wire.UnmarshalSessions(frame.Payload)
176 case wire.MsgCommandResult:192 case wire.MsgCommandResult:
177 return wire.UnmarshalCommandResult(frame.Payload)193 return wire.UnmarshalCommandResult(frame.Payload)
178- case wire.MsgNavigationAction:
179- return wire.UnmarshalNavigationDirective(frame.Payload)
180 case wire.MsgAttachTarget:194 case wire.MsgAttachTarget:
181 return wire.UnmarshalAttachTarget(frame.Payload)195 return wire.UnmarshalAttachTarget(frame.Payload)
182 case wire.MsgRemotePreviewResponse:196 case wire.MsgRemotePreviewResponse:
@@ -193,14 +207,24 @@ func testServerMessage(frame wire.Frame) (protocol.ServerMessage, error) {
193 return wire.UnmarshalRoutePosition(frame.Payload)207 return wire.UnmarshalRoutePosition(frame.Payload)
194 case wire.MsgSamePeerSwitchFailure:208 case wire.MsgSamePeerSwitchFailure:
195 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)209 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
196- case wire.MsgParkedRouteResponse:
197- return wire.UnmarshalParkedRouteResponse(frame.Payload)
198 case wire.MsgNavigationInventoryResponse:210 case wire.MsgNavigationInventoryResponse:
199 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)211 return wire.UnmarshalNavigationInventoryResponse(frame.Payload)
200 case wire.MsgNavigationInventoryDemand:212 case wire.MsgNavigationInventoryDemand:
201 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)213 return wire.UnmarshalNavigationInventoryDemand(frame.Payload)
202 case wire.MsgNavigationInventorySelection:214 case wire.MsgNavigationInventorySelection:
203 return wire.UnmarshalNavigationInventorySelection(frame.Payload)215 return wire.UnmarshalNavigationInventorySelection(frame.Payload)
216+ case wire.MsgPickerOffer:
217+ return wire.UnmarshalPickerOffer(frame.Payload)
218+ case wire.MsgPickerSnapshot:
219+ return wire.UnmarshalPickerSnapshot(frame.Payload)
220+ case wire.MsgPickerClosedServer:
221+ return wire.UnmarshalPickerClosed(frame.Payload)
222+ case wire.MsgPickerResult:
223+ return wire.UnmarshalPickerResult(frame.Payload)
224+ case wire.MsgPickerFailure:
225+ return wire.UnmarshalPickerFailure(frame.Payload)
226+ case wire.MsgPickerPreview:
227+ return wire.UnmarshalPickerPreview(frame.Payload)
204 default:228 default:
205 return nil, errors.New("test client connection: unsupported server frame")229 return nil, errors.New("test client connection: unsupported server frame")
206 }230 }
M internal/usecase/client/ui.go +74 -0
@@ -17,6 +17,80 @@ const uiActionHistory = 64
17 17
18 // UI owns attachment-local action and waiter bookkeeping. It never holds its18 // UI owns attachment-local action and waiter bookkeeping. It never holds its
19 // mutex across terminal access, transport I/O, or predicate evaluation.19 // mutex across terminal access, transport I/O, or predicate evaluation.
20+
21+// pickerOutcomeQueue carries decoded picker operations from the stdin-pump
22+// goroutine to the attach loop. The pump never touches the terminal, the
23+// control channel, or the picker model: it only reports what the user
24+// typed, and the attach loop applies it through its sole terminal writer
25+// and flushes typed sends in controlCh order.
26+//
27+// The queue is bounded: physical input is now a producer, so an unbounded
28+// queue would let a paste or a stuck attach loop accumulate operations
29+// without limit. offer blocks until the loop drains or the attempt ends.
30+type pickerOutcomeQueue struct {
31+ mu sync.Mutex
32+ outcomes []pickerConsumeOutcome
33+ signal chan struct{}
34+ slots chan struct{}
35+ done <-chan struct{}
36+}
37+
38+// pickerOutcomeCapacity bounds queued picker operations between the pump and
39+// the attach loop.
40+const pickerOutcomeCapacity = 256
41+
42+func newPickerOutcomeQueue(done <-chan struct{}) *pickerOutcomeQueue {
43+ queue := &pickerOutcomeQueue{
44+ signal: make(chan struct{}, 1),
45+ slots: make(chan struct{}, pickerOutcomeCapacity),
46+ done: done,
47+ }
48+ for i := 0; i < pickerOutcomeCapacity; i++ {
49+ queue.slots <- struct{}{}
50+ }
51+ return queue
52+}
53+
54+// offer queues one decoded operation, waiting for room. A cancelled attempt
55+// drops it: nothing can apply the operation any more.
56+func (q *pickerOutcomeQueue) offer(outcome pickerConsumeOutcome) {
57+ if q == nil {
58+ return
59+ }
60+ select {
61+ case <-q.slots:
62+ case <-q.done:
63+ return
64+ }
65+ q.mu.Lock()
66+ q.outcomes = append(q.outcomes, outcome)
67+ q.mu.Unlock()
68+ select {
69+ case q.signal <- struct{}{}:
70+ default:
71+ }
72+}
73+
74+// take drains queued operations in order and returns their capacity. The
75+// caller holds no locks; sends keep controlCh ordering because the attach
76+// loop flushes them before processing new frames.
77+func (q *pickerOutcomeQueue) take() []pickerConsumeOutcome {
78+ if q == nil {
79+ return nil
80+ }
81+ q.mu.Lock()
82+ outcomes := q.outcomes
83+ q.outcomes = nil
84+ q.mu.Unlock()
85+ for range outcomes {
86+ select {
87+ case q.slots <- struct{}{}:
88+ default:
89+ }
90+ }
91+ return outcomes
92+}
93+
20 type UI struct {94 type UI struct {
21 state ports.UIState95 state ports.UIState
22 clock ports.Clock96 clock ports.Clock
M internal/usecase/client/ui_actions.go +1 -1
@@ -162,7 +162,7 @@ func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.U
162 defer timer.Stop()162 defer timer.Stop()
163 admissionCtx, cancelAdmission := context.WithCancel(ctx)163 admissionCtx, cancelAdmission := context.WithCancel(ctx)
164 defer cancelAdmission()164 defer cancelAdmission()
165- batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}165+ batch := terminalAutomationRequest{ctx: admissionCtx, owner: u, consumer: consumer, record: terminalReadResult{data: data, source: terminalInputAutomation, generation: request.Generation, actionID: id, endBatch: true, keys: request.Keys, text: request.Text}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}
166 reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {166 reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {
167 u.mu.Lock()167 u.mu.Lock()
168 delete(u.records, id)168 delete(u.records, id)
M internal/usecase/client/ui_remote_test.go +8 -54
@@ -68,7 +68,7 @@ func awaitUIClientMessage[T protocol.ClientMessage](t *testing.T, p *uiTestPeer)
68 }68 }
69 }69 }
70 70
71-func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {71+func TestUIRunnerRemoteHandoffRequiresDestinationFull(t *testing.T) {
72 ctx, cancel := context.WithCancel(context.Background())72 ctx, cancel := context.WithCancel(context.Background())
73 t.Cleanup(cancel)73 t.Cleanup(cancel)
74 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")74 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
@@ -77,17 +77,17 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
77 ui := NewUI(terminal, systemClock{})77 ui := NewUI(terminal, systemClock{})
78 source := newUITestPeer(t, protocol.ConnectionCapabilities{})78 source := newUITestPeer(t, protocol.ConnectionCapabilities{})
79 destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1})79 destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1})
80- picker := newUITestPeer(t, protocol.ConnectionCapabilities{})
81 localDialer := portsmocks.NewMockClientDialer(t)80 localDialer := portsmocks.NewMockClientDialer(t)
82 localDialer.EXPECT().Dial(mock.Anything).Return(source.connection, nil).Once()81 localDialer.EXPECT().Dial(mock.Anything).Return(source.connection, nil).Once()
83- localDialer.EXPECT().Dial(mock.Anything).Return(picker.connection, nil).Once()
84 remoteDialer := portsmocks.NewMockClientDialer(t)82 remoteDialer := portsmocks.NewMockClientDialer(t)
85 remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once()83 remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once()
86 target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote"}84 target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote"}
87- runner := NewRunner(Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true, AttachHandoff: func(offer protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) {85+ deps := Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true}
88- require.Equal(t, "isolated-fixture", offer.Endpoint)86+ deps.HostRegistry = internalStubHostRegistry{resolve: func(endpoint string) (ports.RemoteEndpointBinding, error) {
89- return remoteDialer, AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote", Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "isolated-fixture", ExactTarget: &target}, nil87+ require.Equal(t, "isolated-fixture", endpoint)
90- }})88+ return ports.RemoteEndpointBinding{Dialer: remoteDialer}, nil
89+ }}
90+ runner := NewRunner(deps)
91 done := make(chan error, 1)91 done := make(chan error, 1)
92 go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }()92 go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }()
93 t.Cleanup(func() {93 t.Cleanup(func() {
@@ -118,7 +118,7 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
118 results <- result118 results <- result
119 }()119 }()
120 fence := awaitUIClientMessage[protocol.UIFence](t, source)120 fence := awaitUIClientMessage[protocol.UIFence](t, source)
121- source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", ExactTarget: &target, CauseActionID: fence.ActionID}121+ source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", Intent: protocol.IntentAttach, ExactTarget: &target, CauseActionID: fence.ActionID}
122 awaitUIClientMessage[protocol.Hello](t, destination)122 awaitUIClientMessage[protocol.Hello](t, destination)
123 remoteView := view123 remoteView := view
124 remoteView.Route.Target = target124 remoteView.Route.Target = target
@@ -143,50 +143,4 @@ func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
143 t.Fatal("destination full did not complete handoff")143 t.Fatal("destination full did not complete handoff")
144 }144 }
145 awaitUIClientMessage[protocol.Theme](t, destination)145 awaitUIClientMessage[protocol.Theme](t, destination)
146- startAction := func(generation uint64) {
147- go func() {
148- result, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: generation, Keys: []string{"Enter"}})
149- if err != nil {
150- failures <- err
151- return
152- }
153- results <- result
154- }()
155- }
156- awaitAction := func(generation uint64, target protocol.ExactSessionTarget) {
157- t.Helper()
158- select {
159- case result := <-results:
160- require.Equal(t, ports.UIActionProcessed, result.Status)
161- require.Equal(t, generation, result.Context.Generation)
162- require.Equal(t, target, result.Context.Route.Target)
163- case err := <-failures:
164- t.Fatal(err)
165- case <-time.After(time.Second):
166- t.Fatal("parked handoff did not complete")
167- }
168- }
169- startAction(2)
170- openPicker := awaitUIClientMessage[protocol.UIFence](t, destination)
171- destination.incoming <- protocol.NavigationDirective{Action: protocol.NavigationOpenHomePicker, LeaseID: protocol.ParkedRouteLeaseID{1}, CauseActionID: openPicker.ActionID}
172- prepare := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination)
173- require.Equal(t, protocol.ParkedRoutePrepare, prepare.Action)
174- destination.incoming <- protocol.ParkedRouteResponse{RequestID: prepare.RequestID, Status: protocol.ParkedRouteReady}
175- awaitUIClientMessage[protocol.Hello](t, picker)
176- picker.incoming <- protocol.Welcome{SessionID: "local-session", SessionName: "local", CommittedIdentity: &view.Route}
177- awaitUIClientMessage[protocol.Theme](t, picker)
178- awaitUIClientMessage[protocol.Theme](t, picker)
179- picker.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hpicker")}
180- awaitAction(3, view.Route.Target)
181- startAction(3)
182- selectRemote := awaitUIClientMessage[protocol.UIFence](t, picker)
183- remoteTarget := domain.RemoteSessionTarget{Endpoint: "isolated-fixture", DisplayOrigin: "isolated-fixture", LifecycleID: target.LifecycleID, SessionName: target.SessionName, LiveTabID: "tab"}
184- picker.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", RemoteTarget: &remoteTarget, CauseActionID: selectRemote.ActionID}
185- switchRequest := awaitUIClientMessage[protocol.ParkedRouteRequest](t, destination)
186- require.Equal(t, protocol.ParkedRouteSwitch, switchRequest.Action)
187- destination.incoming <- protocol.ParkedRouteResponse{RequestID: switchRequest.RequestID, Status: protocol.ParkedRouteSwitched}
188- resumedView := remoteView
189- resumedView.Publication = 2
190- destination.incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &resumedView, Data: []byte("\x1b[2J\x1b[Hresumed")}
191- awaitAction(4, target)
192 }146 }
M internal/usecase/daemon/activation_policy_test.go +10 -11
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol/catalogue"11 "github.com/bnema/vev/internal/protocol/catalogue"
12- "github.com/bnema/vev/internal/usecase/picker"
13 )12 )
14 13
15 // TestActivationPolicyTable drives the D5 contract through the picker14 // TestActivationPolicyTable drives the D5 contract through the picker
@@ -26,7 +25,7 @@ func TestActivationPolicyTable(t *testing.T) {
26 name string25 name string
27 host ports.RemoteHostSnapshot26 host ports.RemoteHostSnapshot
28 session catalogue.RemoteCatalogSession27 session catalogue.RemoteCatalogSession
29- activation picker.RemoteActivation28+ activation pickerRemoteActivation
30 reason string29 reason string
31 rowPresent bool30 rowPresent bool
32 }{31 }{
@@ -34,21 +33,21 @@ func TestActivationPolicyTable(t *testing.T) {
34 name: "live reachable attaches",33 name: "live reachable attaches",
35 host: reachableDirectoryHost("user@arch", now),34 host: reachableDirectoryHost("user@arch", now),
36 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},35 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
37- activation: picker.RemoteAttach,36+ activation: pickerRemoteAttach,
38 rowPresent: true,37 rowPresent: true,
39 },38 },
40 {39 {
41 name: "stopped reachable restarts",40 name: "stopped reachable restarts",
42 host: reachableDirectoryHost("user@arch", now),41 host: reachableDirectoryHost("user@arch", now),
43 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs},42 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, Tabs: tabs},
44- activation: picker.RemoteRestart,43+ activation: pickerRemoteRestart,
45 rowPresent: true,44 rowPresent: true,
46 },45 },
47 {46 {
48 name: "broken is diagnostic only",47 name: "broken is diagnostic only",
49 host: reachableDirectoryHost("user@arch", now),48 host: reachableDirectoryHost("user@arch", now),
50 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs},49 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionBroken, Tabs: tabs},
51- activation: picker.RemoteUnavailable,50+ activation: pickerRemoteUnavailable,
52 reason: domain.RemoteReasonSessionBroken,51 reason: domain.RemoteReasonSessionBroken,
53 rowPresent: true,52 rowPresent: true,
54 },53 },
@@ -59,7 +58,7 @@ func TestActivationPolicyTable(t *testing.T) {
59 LastSuccess: now, InventoryKnown: true,58 LastSuccess: now, InventoryKnown: true,
60 },59 },
61 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},60 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
62- activation: picker.RemoteUnavailable,61+ activation: pickerRemoteUnavailable,
63 reason: domain.RemoteReasonVersionMismatch,62 reason: domain.RemoteReasonVersionMismatch,
64 rowPresent: true,63 rowPresent: true,
65 },64 },
@@ -70,7 +69,7 @@ func TestActivationPolicyTable(t *testing.T) {
70 return host69 return host
71 }(),70 }(),
72 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},71 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
73- activation: picker.RemoteAttach,72+ activation: pickerRemoteAttach,
74 rowPresent: true,73 rowPresent: true,
75 },74 },
76 {75 {
@@ -80,7 +79,7 @@ func TestActivationPolicyTable(t *testing.T) {
80 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,79 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
81 },80 },
82 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},81 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
83- activation: picker.RemoteAttach,82+ activation: pickerRemoteAttach,
84 reason: domain.RemoteReasonHostUnreachable,83 reason: domain.RemoteReasonHostUnreachable,
85 rowPresent: true,84 rowPresent: true,
86 },85 },
@@ -91,7 +90,7 @@ func TestActivationPolicyTable(t *testing.T) {
91 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,90 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
92 },91 },
93 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},92 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
94- activation: picker.RemoteAttach,93+ activation: pickerRemoteAttach,
95 reason: domain.RemoteReasonAuthFailure,94 reason: domain.RemoteReasonAuthFailure,
96 rowPresent: true,95 rowPresent: true,
97 },96 },
@@ -102,7 +101,7 @@ func TestActivationPolicyTable(t *testing.T) {
102 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,101 LastSuccess: now.Add(-time.Hour), InventoryKnown: true,
103 },102 },
104 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},103 session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: tabs, ActiveTabID: "tab-1"},
105- activation: picker.RemoteAttach,104+ activation: pickerRemoteAttach,
106 reason: domain.RemoteReasonMalformed,105 reason: domain.RemoteReasonMalformed,
107 rowPresent: true,106 rowPresent: true,
108 },107 },
@@ -117,7 +116,7 @@ func TestActivationPolicyTable(t *testing.T) {
117 seedRemoteDirectory(t, d, host)116 seedRemoteDirectory(t, d, host)
118 117
119 views, _ := d.pickerViews(current, nil)118 views, _ := d.pickerViews(current, nil)
120- var found *picker.SessionView119+ var found *pickerSessionView
121 for i, view := range views {120 for i, view := range views {
122 if view.RemoteHost == "user@arch" && view.Name == "work@arch" {121 if view.RemoteHost == "user@arch" && view.Name == "work@arch" {
123 found = &views[i]122 found = &views[i]
M internal/usecase/daemon/attachment_connection.go +27 -0
@@ -58,6 +58,33 @@ func (s *session) captureAttachmentCapability(ac *attachedClient, tr ports.Serve
58 return captureAttachmentCapability(s, ac, tr)58 return captureAttachmentCapability(s, ac, tr)
59 }59 }
60 60
61+// admitCurrentAttachmentCapabilityEffect re-admits the attachment's current
62+// capability after an exact capability stopped being admissible. It re-reads the
63+// attachment's current session and transport, so it never sends through a stale
64+// capability or resurrects a detached transport. A detached attachment, or one
65+// whose current session no longer registers this link, admits nothing.
66+func admitCurrentAttachmentCapabilityEffect(capability attachmentCapability) (attachmentCapability, *attachmentEffect, bool) {
67+ ac := capability.ac
68+ if ac == nil {
69+ return attachmentCapability{}, nil, false
70+ }
71+ sess := ac.currentAttachmentSession()
72+ if sess == nil {
73+ return attachmentCapability{}, nil, false
74+ }
75+ token, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport())
76+ if !admitted {
77+ return attachmentCapability{}, nil, false
78+ }
79+ // The re-admitted link replaces the superseded capability's identity but
80+ // inherits its post-publication paint semantics, so a recovered cross-session
81+ // first paint still rebases the output chain on the current link.
82+ if capability.rebase {
83+ token.rebase = true
84+ }
85+ return token, effect, true
86+}
87+
61 func (t attachmentCapability) sameIdentity(other attachmentCapability) bool {88 func (t attachmentCapability) sameIdentity(other attachmentCapability) bool {
62 return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac &&89 return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac &&
63 t.generation == other.generation && t.transport.transport == other.transport.transport &&90 t.generation == other.generation && t.transport.transport == other.transport.transport &&
M internal/usecase/daemon/attachment_effect_gate.go +0 -3
@@ -208,9 +208,6 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error {
208 case protocol.AttachTarget:208 case protocol.AttachTarget:
209 navigation.CauseActionID = t.uiActionID209 navigation.CauseActionID = t.uiActionID
210 message = navigation210 message = navigation
211- case protocol.NavigationDirective:
212- navigation.CauseActionID = t.uiActionID
213- message = navigation
214 case protocol.RouteNavigationAction:211 case protocol.RouteNavigationAction:
215 navigation.CauseActionID = t.uiActionID212 navigation.CauseActionID = t.uiActionID
216 message = navigation213 message = navigation
M internal/usecase/daemon/attachment_effect_gate_test.go +36 -4
@@ -784,17 +784,47 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
784 require.Equal(t, 1, testAttachmentTabIndex(target))784 require.Equal(t, 1, testAttachmentTabIndex(target))
785 }785 }
786 786
787+// openPickerForSession opens a navigation interaction on the attachment and
788+// returns a kill selection for the named session. The open runs on a
789+// short-lived effect: the interaction namespace lives on the attachment, not
790+// on the effect that carried its offer.
791+func openPickerForSession(t *testing.T, d *Daemon, sess *session, ac *attachedClient, killSession domain.SessionID) protocol.PickerSelection {
792+ t.Helper()
793+ effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
794+ require.True(t, admitted)
795+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
796+ effect.End()
797+ ac.overlays.pickerMu.Lock()
798+ interaction := ac.overlays.pickerInteraction
799+ revision := ac.overlays.pickerRevisions[servingPickerSourceID]
800+ key := ""
801+ for candidate, target := range ac.overlays.pickerKeys {
802+ if target.Session == killSession {
803+ key = candidate
804+ break
805+ }
806+ }
807+ ac.overlays.pickerMu.Unlock()
808+ require.NotEmpty(t, key, "no picker key resolves to session %s", killSession)
809+ return protocol.PickerSelection{
810+ InteractionID: interaction, SourceID: servingPickerSourceID,
811+ SourceRevision: revision, Key: key, Action: protocol.PickerActionKill,
812+ }
813+}
814+
787 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {815 func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) {
788 p, releasePTY := newBlockingPTY(t)816 p, releasePTY := newBlockingPTY(t)
789 defer releasePTY()817 defer releasePTY()
790 d, sess, old, _ := newManualSessionWithPTYs(t, p)818 d, sess, old, _ := newManualSessionWithPTYs(t, p)
791- d.enterPicker(sess, old)819+ selection := openPickerForSession(t, d, sess, old, sess.id)
792 820
793 oldTransport := old.transport()821 oldTransport := old.transport()
794 rc := d.attachCoordinator(sess, nil, old, true)822 rc := d.attachCoordinator(sess, nil, old, true)
795 token := sess.captureAttachmentCapability(old, oldTransport)823 token := sess.captureAttachmentCapability(old, oldTransport)
796 token.lease = rc.attachmentLease(old)824 token.lease = rc.attachmentLease(old)
797 old.installTestAttachmentCapability(token)825 old.installTestAttachmentCapability(token)
826+ actionEffect, admitted := old.beginAttachmentEffect(token)
827+ require.True(t, admitted)
798 828
799 admissionEnded := make(chan struct{})829 admissionEnded := make(chan struct{})
800 releaseAction := make(chan struct{})830 releaseAction := make(chan struct{})
@@ -809,7 +839,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
809 }839 }
810 actionDone := make(chan struct{})840 actionDone := make(chan struct{})
811 go func() {841 go func() {
812- d.handleAttachmentClientFrame(token, frameInput([]byte("x")))842+ d.resolvePickerSelection(actionEffect, selection)
813 close(actionDone)843 close(actionDone)
814 }()844 }()
815 <-admissionEnded845 <-admissionEnded
@@ -834,13 +864,15 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
834 p, release := newBlockingPTY(t)864 p, release := newBlockingPTY(t)
835 defer release()865 defer release()
836 d, sess, ac, _ := newManualSessionWithPTYs(t, p)866 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
837- d.enterPicker(sess, ac)867+ selection := openPickerForSession(t, d, sess, ac, sess.id)
838 rc := d.attachCoordinator(sess, nil, ac, true)868 rc := d.attachCoordinator(sess, nil, ac, true)
839 token := sess.captureAttachmentCapability(ac, ac.transport())869 token := sess.captureAttachmentCapability(ac, ac.transport())
840 token.lease = rc.attachmentLease(ac)870 token.lease = rc.attachmentLease(ac)
841 ac.installTestAttachmentCapability(token)871 ac.installTestAttachmentCapability(token)
872+ actionEffect, admitted := ac.beginAttachmentEffect(token)
873+ require.True(t, admitted)
842 874
843- d.handleAttachmentClientFrame(token, frameInput([]byte("x")))875+ d.resolvePickerSelection(actionEffect, selection)
844 876
845 d.mu.Lock()877 d.mu.Lock()
846 _, registered := d.sessions[sess.id]878 _, registered := d.sessions[sess.id]
M internal/usecase/daemon/attachment_output.go +12 -0
@@ -578,6 +578,18 @@ func (s *attachmentOutput) atCapacity() bool {
578 return s.outstandingAtomic.Load() >= maxOutstanding578 return s.outstandingAtomic.Load() >= maxOutstanding
579 }579 }
580 580
581+// committedState reports the latest committed output state number. It takes
582+// only the view lock, so barrier snapshots can read it without joining the
583+// sendMu-ordered paint path.
584+func (s *attachmentOutput) committedState() uint64 {
585+ if s == nil {
586+ return 0
587+ }
588+ s.lockView()
589+ defer s.unlockView()
590+ return s.next
591+}
592+
581 func (s *attachmentOutput) outstanding() uint64 {593 func (s *attachmentOutput) outstanding() uint64 {
582 if s == nil {594 if s == nil {
583 return 0595 return 0
M internal/usecase/daemon/attachment_transition.go +21 -0
@@ -153,14 +153,35 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
153 if err != nil {153 if err != nil {
154 return result, err154 return result, err
155 }155 }
156+ return d.finishAttachmentTransition(req, result)
157+}
158+
159+// finishAttachmentTransition shares committed identity and geometry effects with
160+// composite topology transactions. It runs after publication locks are released.
161+func (d *Daemon) finishAttachmentTransition(req attachmentTransitionRequest, result attachmentTransitionResult) (attachmentTransitionResult, error) {
156 // A not-ready publication is still inside the Hello handshake. Welcome162 // A not-ready publication is still inside the Hello handshake. Welcome
157 // carries the committed identity for those attachments and must remain the163 // carries the committed identity for those attachments and must remain the
158 // first server frame. Ready transitions notify an already-running client.164 // first server frame. Ready transitions notify an already-running client.
159 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {165 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
166+ if d.beforeAttachmentTransitionIdentityAdmission != nil {
167+ d.beforeAttachmentTransitionIdentityAdmission(result.published)
168+ }
160 identityErr := errAttachmentTransition169 identityErr := errAttachmentTransition
161 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {170 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
162 identityErr = d.sendCommittedRouteIdentityForAttachment(effect)171 identityErr = d.sendCommittedRouteIdentityForAttachment(effect)
163 effect.End()172 effect.End()
173+ } else if token, effect, admitted := admitCurrentAttachmentCapabilityEffect(result.published); admitted {
174+ // The exact published capability stopped being admissible while its
175+ // attachment stayed live: a concurrent transition or link replacement
176+ // superseded it after publication released the architecture locks. The
177+ // committed identity is still owed to that live attachment, so re-admit
178+ // its current capability and report it back so the caller's first paint
179+ // and correlated completion use the same authority. A detached attachment
180+ // or one whose current session no longer registers this link admits
181+ // nothing and fails closed below; no stale transport is retried.
182+ result.published = token
183+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect)
184+ effect.End()
164 }185 }
165 if identityErr != nil {186 if identityErr != nil {
166 d.abortPublishedAttachmentTransition(result)187 d.abortPublishedAttachmentTransition(result)
M internal/usecase/daemon/attention.go +29 -0
@@ -139,6 +139,26 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
139 }139 }
140 }140 }
141 141
142+// refreshClientPickers republishes the picker source of every attachment whose
143+// interaction the client presents. A bell raised or cleared while that modal is
144+// open must reach it, and client-owned presentation suppresses the daemon's own
145+// paints: only a newer source revision updates the client's rows. Unchanged
146+// line sets publish nothing, so the attention pulse stays cheap.
147+func (d *Daemon) refreshClientPickers() {
148+ d.mu.Lock()
149+ sessions := sessionsSnapshot(d.sessions)
150+ d.mu.Unlock()
151+
152+ for _, sess := range sessions {
153+ for _, ac := range sess.snapshotAttachments() {
154+ if ac.overlays == nil || !ac.overlays.pickerClientActive() {
155+ continue
156+ }
157+ d.refreshPickerSnapshot(ac)
158+ }
159+ }
160+}
161+
142 func (d *Daemon) repaintAllAttachedClients() {162 func (d *Daemon) repaintAllAttachedClients() {
143 d.mu.Lock()163 d.mu.Lock()
144 sessions := sessionsSnapshot(d.sessions)164 sessions := sessionsSnapshot(d.sessions)
@@ -236,6 +256,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
236 }256 }
237 case <-timer.C():257 case <-timer.C():
238 d.advanceAttentionFrame()258 d.advanceAttentionFrame()
259+ d.refreshClientPickers()
239 d.repaintAllAttachedClients()260 d.repaintAllAttachedClients()
240 }261 }
241 262
@@ -256,6 +277,14 @@ func (d *Daemon) anyAttention() bool {
256 if sess.anyAttention() {277 if sess.anyAttention() {
257 return true278 return true
258 }279 }
280+ for _, ac := range sess.snapshotAttachments() {
281+ snapshot := ac.routeSnapshotCopy()
282+ for _, entry := range snapshot.Entries {
283+ if entry.Attention {
284+ return true
285+ }
286+ }
287+ }
259 }288 }
260 return false289 return false
261 }290 }
M internal/usecase/daemon/client.go +10 -24
@@ -43,7 +43,6 @@ type attachedClient struct {
43 clientID [16]byte43 clientID [16]byte
44 terminalCapabilities terminalcap.Capabilities44 terminalCapabilities terminalcap.Capabilities
45 navigationCapabilities protocol.NavigationCapabilities45 navigationCapabilities protocol.NavigationCapabilities
46- startupOverlay protocol.StartupOverlay
47 // lifecycle is the sole authority for attachment capability publication,46 // lifecycle is the sole authority for attachment capability publication,
48 // effect admission, transition freeze/drain, and connection generation.47 // effect admission, transition freeze/drain, and connection generation.
49 lifecycle attachmentLifecycle48 lifecycle attachmentLifecycle
@@ -92,10 +91,6 @@ type attachedClient struct {
92 pendingRouteIdentity bool91 pendingRouteIdentity bool
93 samePeerOfferMu sync.Mutex92 samePeerOfferMu sync.Mutex
94 samePeerOffer *protocol.ExactSessionTarget93 samePeerOffer *protocol.ExactSessionTarget
95- parkedRouteMu sync.Mutex
96- parkedRoute *parkedRouteLease
97- parkedRouteOutput atomic.Bool
98- parkedRouteFullPending atomic.Bool
99 routeAttentionSubscription protocol.RouteAttentionSubscription94 routeAttentionSubscription protocol.RouteAttentionSubscription
100 routeSubscriptionTransport transportSnapshot95 routeSubscriptionTransport transportSnapshot
101 routeObservationAfter map[protocol.RouteAttentionTarget]time.Time96 routeObservationAfter map[protocol.RouteAttentionTarget]time.Time
@@ -392,15 +387,15 @@ func (ac *attachedClient) setRouteAttentionSubscription(subscription protocol.Ro
392 ac.routeAttentionSubscription = subscription387 ac.routeAttentionSubscription = subscription
393 }388 }
394 389
395-func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.ExactSessionTarget, bool) {390+func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.RouteAttentionTarget, bool) {
396 ac.routeMu.RLock()391 ac.routeMu.RLock()
397 defer ac.routeMu.RUnlock()392 defer ac.routeMu.RUnlock()
398 for _, target := range ac.routeAttentionSubscription.Targets {393 for _, target := range ac.routeAttentionSubscription.Targets {
399- if target.Ref == ref && target.SourceKey == "" {394+ if target.Ref == ref {
400- return target.Target, true395+ return target, true
401 }396 }
402 }397 }
403- return protocol.ExactSessionTarget{}, false398+ return protocol.RouteAttentionTarget{}, false
404 }399 }
405 400
406 func (ac *attachedClient) ackOutputState(epoch, state uint64) {401 func (ac *attachedClient) ackOutputState(epoch, state uint64) {
@@ -508,9 +503,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
508 if !ac.transportSnapshotCurrent(expected) {503 if !ac.transportSnapshotCurrent(expected) {
509 return expected.transport, errTransportReplaced504 return expected.transport, errTransportReplaced
510 }505 }
511- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
512- return expected.transport, nil
513- }
514 ac.output.lockView()506 ac.output.lockView()
515 defer ac.output.unlockView()507 defer ac.output.unlockView()
516 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())508 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -525,9 +517,6 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
525 if !ac.transportSnapshotCurrent(expected) {517 if !ac.transportSnapshotCurrent(expected) {
526 return errTransportReplaced518 return errTransportReplaced
527 }519 }
528- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
529- return nil
530- }
531 ac.output.lockView()520 ac.output.lockView()
532 defer ac.output.unlockView()521 defer ac.output.unlockView()
533 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())522 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
@@ -630,7 +619,6 @@ type attachClientOptions struct {
630 terminalCapabilities terminalcap.Capabilities619 terminalCapabilities terminalcap.Capabilities
631 capabilitiesSet bool620 capabilitiesSet bool
632 navigationCapabilities protocol.NavigationCapabilities621 navigationCapabilities protocol.NavigationCapabilities
633- startupOverlay protocol.StartupOverlay
634 }622 }
635 623
636 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {624 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -709,7 +697,6 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
709 clientID: opts.clientID,697 clientID: opts.clientID,
710 terminalCapabilities: opts.terminalCapabilities,698 terminalCapabilities: opts.terminalCapabilities,
711 navigationCapabilities: opts.navigationCapabilities,699 navigationCapabilities: opts.navigationCapabilities,
712- startupOverlay: opts.startupOverlay,
713 resumeCapable: opts.resumeCapable,700 resumeCapable: opts.resumeCapable,
714 resumeToken: resumeToken,701 resumeToken: resumeToken,
715 }702 }
@@ -802,8 +789,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
802 // attachmentOutput publishes capacity atomically. Do not take789 // attachmentOutput publishes capacity atomically. Do not take
803 // attached.sendMu here: a slow transport may be holding it for an790 // attached.sendMu here: a slow transport may be holding it for an
804 // in-flight Send, and that peer must not gate healthy attachments.791 // in-flight Send, and that peer must not gate healthy attachments.
805- return attached == nil ||792+ return attached == nil || attached.output == nil || !attached.output.atCapacity()
806- (!attached.parkedRouteOutput.Load() && (attached.output == nil || !attached.output.atCapacity()))
807 },793 },
808 })794 })
809 installAttachmentRenderCoordinator(entry, rc)795 installAttachmentRenderCoordinator(entry, rc)
@@ -994,11 +980,11 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom
994 }980 }
995 ac.sendMu.Unlock()981 ac.sendMu.Unlock()
996 982
997- if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {983+ if !sameSize && ac.overlays != nil && ac.overlays.pickerClientActive() {
998- // The picker preview geometry is attachment-local. A resize must984+ // A client-owned interaction renders from its own size: a resize
999- // invalidate any in-flight remote request and fetch the selected row985+ // bumps the snapshot revision so the client never repaints against a
1000- // at the new bounded preview dimensions.986+ // stale size epoch.
1001- d.registerPreviewForSelection(ac)987+ d.refreshPickerSnapshot(ac)
1002 }988 }
1003 989
1004 sess := effect.sess990 sess := effect.sess
M internal/usecase/daemon/client_frame_routing.go +25 -2
@@ -137,10 +137,9 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
137 d.resetOutput(effect)137 d.resetOutput(effect)
138 case protocol.SamePeerSwitchRequest:138 case protocol.SamePeerSwitchRequest:
139 d.switchSamePeerForAttachment(effect, message)139 d.switchSamePeerForAttachment(effect, message)
140- case protocol.ParkedRouteRequest:
141- d.handleParkedRouteRequest(effect, message)
142 case protocol.RecentRouteSnapshot:140 case protocol.RecentRouteSnapshot:
143 replayIdentity := effect.ac.setRouteSnapshot(message)141 replayIdentity := effect.ac.setRouteSnapshot(message)
142+ d.pokeAttentionTicker()
144 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot")143 d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot")
145 if !replayIdentity {144 if !replayIdentity {
146 break145 break
@@ -206,6 +205,30 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
206 d.refreshPalette(effect.ac)205 d.refreshPalette(effect.ac)
207 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")206 d.invalidateRender(effect.sess, effect.ac, true, "client_frame_routing.go:inventory-publication")
208 }207 }
208+ case protocol.PickerBegin:
209+ if effect.ac == nil || effect.ac.overlays == nil {
210+ break
211+ }
212+ if err := d.openPickerForAttachment(effect.ac, effect, message.Intent, moveSourceLocator{}, message.RequestID); err != nil {
213+ d.reportAttachmentError(effect.sess, err)
214+ }
215+ case protocol.PickerClose:
216+ if effect.ac == nil || effect.ac.overlays == nil {
217+ break
218+ }
219+ if d.closePickerForAttachment(effect.ac, effect, message.InteractionID) {
220+ // The client retired the interaction: force the authoritative
221+ // full paint its release path waits for before normal input and
222+ // action completion resume.
223+ if sess := effect.ac.currentAttachmentSession(); sess != nil {
224+ d.invalidateRender(sess, effect.ac, true, "client_frame_routing.go:picker-close")
225+ }
226+ }
227+ case protocol.PickerSelection:
228+ effect.uiActionID = message.CauseActionID
229+ d.resolvePickerSelection(effect, message)
230+ case protocol.PickerPreviewRequest:
231+ d.handlePickerPreviewForAttachment(effect, message)
209 case protocol.NavigationInventoryFailure:232 case protocol.NavigationInventoryFailure:
210 overlays := effect.ac.overlays233 overlays := effect.ac.overlays
211 if overlays == nil {234 if overlays == nil {
M internal/usecase/daemon/client_transport_errors.go +2 -4
@@ -70,7 +70,6 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
70 return70 return
71 }71 }
72 ac.clearSamePeerOffer()72 ac.clearSamePeerOffer()
73- ac.clearParkedRoute()
74 if d.afterClientGoneDetach != nil {73 if d.afterClientGoneDetach != nil {
75 d.afterClientGoneDetach()74 d.afterClientGoneDetach()
76 }75 }
@@ -99,7 +98,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
99 // attachment parking marker left by a raced non-explicit teardown so98 // attachment parking marker left by a raced non-explicit teardown so
100 // IntentResume waiters are not stranded on a never-published park.99 // IntentResume waiters are not stranded on a never-published park.
101 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)100 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
102- d.closePicker(ac)101+ d.retirePicker(ac)
103 d.closePalette(ac)102 d.closePalette(ac)
104 if err := d.cleanupAttachmentOutput(ac); err != nil {103 if err := d.cleanupAttachmentOutput(ac); err != nil {
105 d.log.Warn("output cleanup failed during detach", "err", err, "session", name)104 d.log.Warn("output cleanup failed during detach", "err", err, "session", name)
@@ -180,7 +179,6 @@ func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentCapability, f
180 179
181 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.ServerConnection) {180 func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed ports.ServerConnection) {
182 ac.clearSamePeerOffer()181 ac.clearSamePeerOffer()
183- ac.clearParkedRoute()
184 name := sess.nameSnapshot()182 name := sess.nameSnapshot()
185 if rc := sess.renderCoordinator(); rc != nil {183 if rc := sess.renderCoordinator(); rc != nil {
186 rc.noteDetach(ac)184 rc.noteDetach(ac)
@@ -192,7 +190,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
192 return190 return
193 }191 }
194 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)192 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
195- d.closePicker(ac)193+ d.retirePicker(ac)
196 d.closePalette(ac)194 d.closePalette(ac)
197 if err := d.cleanupAttachmentOutput(ac); err != nil {195 if err := d.cleanupAttachmentOutput(ac); err != nil {
198 d.log.Warn("output cleanup failed after send error", "err", err, "session", name)196 d.log.Warn("output cleanup failed after send error", "err", err, "session", name)
M internal/usecase/daemon/clipboard.go +11 -3
@@ -38,6 +38,12 @@ func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip proto
38 if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {38 if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {
39 return39 return
40 }40 }
41+ // A client-owned picker owns the user input for this attachment: a
42+ // clipboard path must not reach the session while it is open, exactly
43+ // like a keystroke. The client re-sends after the interaction closes.
44+ if effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() {
45+ return
46+ }
41 path, err := d.writeClipboardImageForAttachment(effect, ip)47 path, err := d.writeClipboardImageForAttachment(effect, ip)
42 if err != nil {48 if err != nil {
43 d.log.Error("writing clipboard image failed", "err", err)49 d.log.Error("writing clipboard image failed", "err", err)
@@ -179,6 +185,11 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect
179 if effect != nil && !effect.current() {185 if effect != nil && !effect.current() {
180 return186 return
181 }187 }
188+ // The injection point is the last gate: the interaction may have opened
189+ // between admission and here.
190+ if effect != nil && effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() {
191+ return
192+ }
182 d.writeToPane(sess, p, data)193 d.writeToPane(sess, p, data)
183 }194 }
184 195
@@ -269,9 +280,6 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
269 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {280 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
270 return errAttachmentTransition281 return errAttachmentTransition
271 }282 }
272- if ac.parkedRouteOutput.Load() || ac.parkedRouteFullPending.Load() {
273- return nil
274- }
275 if !beginClipboardOwnerSend(item.owner, ticket, expected) {283 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
276 return errAttachmentTransition284 return errAttachmentTransition
277 }285 }
M internal/usecase/daemon/copy_scroll_animation.go +1 -1
@@ -96,7 +96,7 @@ func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (boo
96 // Check presentation ownership before entering the copy lock. A96 // Check presentation ownership before entering the copy lock. A
97 // callback never locks another overlay or a session under copyMu.97 // callback never locks another overlay or a session under copyMu.
98 visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() &&98 visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() &&
99- !rt.pickerActive() && !rt.noticesActive() && !rt.resizeModeActive()99+ !rt.pickerClientActive() && !rt.noticesActive() && !rt.resizeModeActive()
100 rt.copyMu.Lock()100 rt.copyMu.Lock()
101 if rt.copyScroll.timer.timer != timer {101 if rt.copyScroll.timer.timer != timer {
102 rt.copyMu.Unlock()102 rt.copyMu.Unlock()
M internal/usecase/daemon/daemon.go +13 -7
@@ -84,9 +84,6 @@ type Daemon struct {
84 lastAllocatedCreatedAt int6484 lastAllocatedCreatedAt int64
85 mruSeq atomic.Uint6485 mruSeq atomic.Uint64
86 creationRequestSeq atomic.Uint6486 creationRequestSeq atomic.Uint64
87- // pickerSort is the picker ordering mode for this daemon's lifetime
88- // (pickerSortMode); not persisted across restarts.
89- pickerSort atomic.Uint32
90 // closing marks that shutdown has irreversibly begun. It is set under mu,87 // closing marks that shutdown has irreversibly begun. It is set under mu,
91 // atomically with the event that makes shutdown inevitable (the registry88 // atomically with the event that makes shutdown inevitable (the registry
92 // emptying in killSession, or shutdownAll starting), and checked by route89 // emptying in killSession, or shutdownAll starting), and checked by route
@@ -170,6 +167,11 @@ type Daemon struct {
170 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected167 // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
171 // attachment gates are frozen and drained, before architecture publication.168 // attachment gates are frozen and drained, before architecture publication.
172 afterAttachmentEffectsFrozen func()169 afterAttachmentEffectsFrozen func()
170+ // beforeAttachmentTransitionIdentityAdmission is a deterministic test seam
171+ // after a ready transition published its capability and before the committed
172+ // route identity is admitted, so tests can supersede that exact capability in
173+ // the window before the transition's first paint and completion.
174+ beforeAttachmentTransitionIdentityAdmission func(attachmentCapability)
173 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order175 // afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
174 // seam used by transition validation tests.176 // seam used by transition validation tests.
175 afterAttachmentTransitionCoordinatorsLocked func()177 afterAttachmentTransitionCoordinatorsLocked func()
@@ -186,6 +188,10 @@ type Daemon struct {
186 afterMoveTabSourceSnapshot func()188 afterMoveTabSourceSnapshot func()
187 beforeMovePaneCommit func()189 beforeMovePaneCommit func()
188 beforeMoveTabCommit func()190 beforeMoveTabCommit func()
191+ // beforeMoveFollowCompletion is a deterministic test seam after the composite
192+ // follow's first paint and before the postcommit admits the capability that
193+ // reports completion, so tests can supersede the published capability.
194+ beforeMoveFollowCompletion func(attachmentCapability)
189 // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the195 // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
190 // attachment gate but before it checks session ownership.196 // attachment gate but before it checks session ownership.
191 afterDetachAttachmentEffectsFrozen func()197 afterDetachAttachmentEffectsFrozen func()
@@ -1145,6 +1151,10 @@ func (d *Daemon) handleConn(tr ports.ServerConnection) {
1145 stopTransport()1151 stopTransport()
1146 finishHandshake()1152 finishHandshake()
1147 d.handleNavigationInventory(tr, message)1153 d.handleNavigationInventory(tr, message)
1154+ case protocol.PickerControlRequest:
1155+ stopTransport()
1156+ finishHandshake()
1157+ d.handlePickerControl(tr, message)
1148 case protocol.Kill:1158 case protocol.Kill:
1149 stopTransport()1159 stopTransport()
1150 finishHandshake()1160 finishHandshake()
@@ -1414,9 +1424,6 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1414 failAttachment()1424 failAttachment()
1415 return1425 return
1416 }1426 }
1417- if ac.startupOverlay == protocol.StartupOverlaySessionPicker {
1418- d.enterPicker(sess, ac)
1419- }
1420 postWelcomeTicket.End()1427 postWelcomeTicket.End()
1421 stopHandshakeTransport()1428 stopHandshakeTransport()
1422 finishHandshake()1429 finishHandshake()
@@ -1479,7 +1486,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1479 resumeCapable: true,1486 resumeCapable: true,
1480 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1487 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1481 navigationCapabilities: h.NavigationCapabilities,1488 navigationCapabilities: h.NavigationCapabilities,
1482- startupOverlay: h.StartupOverlay,
1483 terminalCapabilities: terminalCapabilities,1489 terminalCapabilities: terminalCapabilities,
1484 capabilitiesSet: true,1490 capabilitiesSet: true,
1485 }1491 }
D internal/usecase/daemon/home_picker_navigation_test.go +0 -86
@@ -1,86 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/protocol"
10- "github.com/bnema/vev/internal/protocol/wire"
11-)
12-
13-func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) {
14- for _, tc := range []struct {
15- name string
16- sameSession bool
17- homePicker bool
18- }{
19- {name: "different local session", homePicker: true},
20- {name: "backing local session", homePicker: true, sameSession: true},
21- {name: "ordinary local session switch"},
22- {name: "ordinary local tab switch", sameSession: true},
23- } {
24- t.Run(tc.name, func(t *testing.T) {
25- d, source, ac, sends, releases := newManualTabSession(t, 2)
26- defer releaseAll(releases)
27- source.name = "home"
28- source.incarnation = domain.SessionLifecycleID{1}
29- target := source
30- if !tc.sameSession {
31- target = &session{
32- sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})},
33- ctx: source.ctx, cancel: func() {},
34- tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})},
35- }
36- for _, tab := range target.tabs {
37- publishTiledPaneOwners(target, tab)
38- }
39- d.sessions[target.id] = target
40- }
41- target.tabs[1].name = "chosen-tab"
42- if tc.homePicker {
43- ac.startupOverlay = protocol.StartupOverlaySessionPicker
44- ac.navigationCapabilities = protocol.NavigationCapabilityBack
45- }
46- ref := protocol.RouteRef{Key: 7, Generation: 1}
47- ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
48- Generation: 1, Active: ref,
49- ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation,
50- Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"},
51- Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote},
52- })
53- effect := beginRecentRoutePaletteEffect(t, d, source, ac)
54- d.enterPicker(source, ac)
55- // Exercise the real picker model and input dispatch, not a fabricated
56- // AttachTarget: navigation selects tab rows, including search hits.
57- d.handleInputForAttachment(effect, []byte("/chosen-tab"))
58- ac.overlays.pickerMu.Lock()
59- selected, ok := ac.overlays.picker.Selected()
60- ac.overlays.pickerMu.Unlock()
61- require.True(t, ok)
62- require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID)
63- d.handleInputForAttachment(effect, []byte("\r"))
64-
65- if !tc.homePicker {
66- require.Same(t, target, ac.currentAttachmentSession())
67- require.Equal(t, selected.TabID, ac.viewSnapshot().tabID)
68- return
69- }
70- require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment")
71- var handoff *protocol.AttachTarget
72- for len(sends) != 0 {
73- frame := <-sends
74- if frame.Type == wire.MsgAttachTarget {
75- decoded, err := wire.UnmarshalAttachTarget(frame.Payload)
76- require.NoError(t, err)
77- handoff = &decoded
78- }
79- }
80- require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route")
81- require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget)
82- require.Equal(t, selected.TabID, handoff.PreferredTabID)
83- require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place")
84- })
85- }
86-}
D internal/usecase/daemon/home_picker_status_test.go +0 -75
@@ -1,75 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/protocol"
10-)
11-
12-func TestHomePickerStatusKeepsActiveRemoteRoute(t *testing.T) {
13- for _, tc := range []struct {
14- name string
15- homePicker bool
16- noSnapshot bool
17- staleRef bool
18- ephemeral bool
19- wantSession string
20- }{
21- {name: "ordinary local picker ignores unrelated route", wantSession: "vev"},
22- {name: "remote home picker", homePicker: true, wantSession: "misc@igor"},
23- {name: "ephemeral remote route", homePicker: true, ephemeral: true, wantSession: "misc*@igor"},
24- {name: "before route publication", homePicker: true, noSnapshot: true},
25- {name: "inconsistent active reference", homePicker: true, staleRef: true},
26- } {
27- t.Run(tc.name, func(t *testing.T) {
28- d, sess, ac, _, releases := newManualTabSession(t, 1)
29- defer releases[0]()
30- sess.name = "vev"
31- sess.incarnation = domain.SessionLifecycleID{1}
32- d.barScripts = &barScriptState{outputs: map[domain.SessionID]barScriptOutputs{
33- sess.id: {topRight: "local top script", bottomRight: "local bottom script"},
34- }}
35- if tc.homePicker {
36- ac.startupOverlay = protocol.StartupOverlaySessionPicker
37- ac.navigationCapabilities = protocol.NavigationCapabilityBack
38- }
39- ref := protocol.RouteRef{Key: 2, Generation: 1}
40- snapshot := protocol.RecentRouteSnapshot{
41- Generation: 2, Active: ref,
42- ActiveEntry: protocol.RecentRouteEntry{
43- Key: ref.Key, Generation: ref.Generation,
44- Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "misc"},
45- Name: "misc", HostLabel: "dev@igor", Kind: protocol.RouteKindRemote, Ephemeral: tc.ephemeral,
46- },
47- }
48- if tc.staleRef {
49- snapshot.Active.Generation++
50- }
51- if !tc.noSnapshot {
52- ac.setRouteSnapshot(snapshot)
53- }
54- d.enterPicker(sess, ac)
55- require.True(t, ac.overlays.pickerActive())
56- bars := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
57- frame := composeFrame(capturedRenderState{window: domain.Size{Cols: 80, Rows: 24}, bars: bars}, composeCacheInput{}).frame
58- require.Equal(t, tc.wantSession, bars.status.session)
59- if tc.wantSession != "" {
60- require.Contains(t, rowText(frame.Row(frame.Height-1)), tc.wantSession)
61- }
62- if tc.homePicker {
63- require.Empty(t, bars.status.tabs, "local tabs must not masquerade as remote tabs")
64- require.Empty(t, bars.topRight)
65- require.Empty(t, bars.bottomRight)
66- require.NotContains(t, rowText(frame.Row(frame.Height-1)), "vev")
67- } else {
68- require.Len(t, bars.status.tabs, 1)
69- require.Equal(t, "local top script", bars.topRight)
70- require.Equal(t, "local bottom script", bars.bottomRight)
71- }
72- require.Equal(t, "vev", sess.statusSegments(true).session, "picker presentation must not rename its local backing session")
73- })
74- }
75-}
M internal/usecase/daemon/input.go +12 -1
@@ -29,6 +29,12 @@ func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ u
29 29
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31 ac.initOverlays()31 ac.initOverlays()
32+ if ac.overlays.pickerClientActive() {
33+ // The client-owned picker owns the input: raw keys and mouse events
34+ // must not reach the session while it is open. Only its typed
35+ // selection and close messages act, on the control path.
36+ return
37+ }
32 ac.mouseScan.Scan(data,38 ac.mouseScan.Scan(data,
33 func(ev mouse.Event) { d.handleMouse(ac, ev) },39 func(ev mouse.Event) { d.handleMouse(ac, ev) },
34 func(b []byte) {40 func(b []byte) {
@@ -43,6 +49,11 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
43 func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) {49 func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) {
44 ac := effect.ac50 ac := effect.ac
45 ac.initOverlays()51 ac.initOverlays()
52+ if ac.overlays.pickerClientActive() {
53+ // Same rule as handleInput: the client-owned picker consumes user
54+ // input, mouse included, until it closes.
55+ return
56+ }
46 ac.mouseScan.Scan(data,57 ac.mouseScan.Scan(data,
47 func(ev mouse.Event) {58 func(ev mouse.Event) {
48 if effect.current() {59 if effect.current() {
@@ -72,7 +83,7 @@ func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
72 frameEvent := ev83 frameEvent := ev
73 ac.initOverlays()84 ac.initOverlays()
74 rt := ac.overlays85 rt := ac.overlays
75- if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() {86+ if rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() {
76 return87 return
77 }88 }
78 sess := ac.currentSession()89 sess := ac.currentSession()
M internal/usecase/daemon/input_test.go +2 -23
@@ -635,11 +635,10 @@ func TestOverlayInputPrecedence(t *testing.T) {
635 check func(t *testing.T, ac *attachedClient, writes chan []byte)635 check func(t *testing.T, ac *attachedClient, writes chan []byte)
636 }{636 }{
637 {637 {
638- name: "prompt before palette picker copy and normal",638+ name: "prompt before palette copy and normal",
639 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {639 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {
640 t.Helper()640 t.Helper()
641 d.enterCopyMode(sess, ac)641 d.enterCopyMode(sess, ac)
642- d.enterPicker(sess, ac)
643 d.enterPalette(sess, ac)642 d.enterPalette(sess, ac)
644 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })643 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
645 },644 },
@@ -648,41 +647,21 @@ func TestOverlayInputPrecedence(t *testing.T) {
648 t.Helper()647 t.Helper()
649 require.Equal(t, "x", ac.overlays.prompt.Value())648 require.Equal(t, "x", ac.overlays.prompt.Value())
650 require.Equal(t, "", ac.overlays.palette.Query())649 require.Equal(t, "", ac.overlays.palette.Query())
651- require.True(t, ac.overlays.pickerActive())
652 require.True(t, ac.overlays.copyActive())650 require.True(t, ac.overlays.copyActive())
653 requireNoPTYWrite(t, writes)651 requireNoPTYWrite(t, writes)
654 },652 },
655 },653 },
656 {654 {
657- name: "palette before picker copy and normal",655+ name: "palette before copy and normal",
658 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {656 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {
659 t.Helper()657 t.Helper()
660 d.enterCopyMode(sess, ac)658 d.enterCopyMode(sess, ac)
661- d.enterPicker(sess, ac)
662 d.enterPalette(sess, ac)659 d.enterPalette(sess, ac)
663 },660 },
664 input: []byte("x"),661 input: []byte("x"),
665 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {662 check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
666 t.Helper()663 t.Helper()
667 require.Equal(t, "x", ac.overlays.palette.Query())664 require.Equal(t, "x", ac.overlays.palette.Query())
668- require.True(t, ac.overlays.pickerActive())
669- require.True(t, ac.overlays.copyActive())
670- requireNoPTYWrite(t, writes)
671- },
672- },
673- {
674- name: "picker before copy and normal",
675- setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan wire.Frame) {
676- t.Helper()
677- d.enterCopyMode(sess, ac)
678- d.enterPicker(sess, ac)
679- },
680- input: []byte("j"),
681- check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
682- t.Helper()
683- target, ok := ac.overlays.picker.Selected()
684- require.True(t, ok)
685- require.Equal(t, 1, target.TabIndex)
686 require.True(t, ac.overlays.copyActive())665 require.True(t, ac.overlays.copyActive())
687 requireNoPTYWrite(t, writes)666 requireNoPTYWrite(t, writes)
688 },667 },
M internal/usecase/daemon/move.go +6 -0
@@ -10,6 +10,9 @@ import (
10 // immutable identity. Session names are advisory; the ID and incarnation are10 // immutable identity. Session names are advisory; the ID and incarnation are
11 // the commit-time authority.11 // the commit-time authority.
12 type movePaneRequest struct {12 type movePaneRequest struct {
13+ Follow bool
14+ BeforeFollow func() error
15+ AfterFollow func(*attachmentEffect)
13 Attachment *attachedClient16 Attachment *attachedClient
14 AttachmentCapability attachmentCapability17 AttachmentCapability attachmentCapability
15 Source moveSessionLocator18 Source moveSessionLocator
@@ -20,6 +23,9 @@ type movePaneRequest struct {
20 }23 }
21 24
22 type moveTabRequest struct {25 type moveTabRequest struct {
26+ Follow bool
27+ BeforeFollow func() error
28+ AfterFollow func(*attachmentEffect)
23 Attachment *attachedClient29 Attachment *attachedClient
24 AttachmentCapability attachmentCapability30 AttachmentCapability attachmentCapability
25 Source moveSessionLocator31 Source moveSessionLocator
M internal/usecase/daemon/move_pane_admission.go +3 -0
@@ -26,6 +26,9 @@ type movePaneAdmission struct {
26 func (p *movePaneTopology) transactionRequest() moveTransactionRequest {26 func (p *movePaneTopology) transactionRequest() moveTransactionRequest {
27 req := p.req27 req := p.req
28 return moveTransactionRequest{28 return moveTransactionRequest{
29+ follow: req.Follow,
30+ beforeFollow: req.BeforeFollow,
31+ afterFollow: req.AfterFollow,
29 operation: "pane",32 operation: "pane",
30 attachment: req.Attachment,33 attachment: req.Attachment,
31 attachmentCapability: req.AttachmentCapability,34 attachmentCapability: req.AttachmentCapability,
M internal/usecase/daemon/move_picker.go +34 -126
@@ -3,9 +3,27 @@ package daemon
3 3
4 import (4 import (
5 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/protocol"
6 "github.com/bnema/vev/internal/usecase/picker"7 "github.com/bnema/vev/internal/usecase/picker"
7 )8 )
8 9
10+// moveSourceLocator captures the exact source of one move at command time. The
11+// presenting client never supplies it: only the daemon resolves it, and only
12+// for the interaction that captured it.
13+type moveSessionLocator struct {
14+ ID domain.SessionID
15+ Incarnation domain.IncarnationID
16+ Name string
17+}
18+
19+type moveSourceLocator struct {
20+ Session moveSessionLocator
21+ TabID domain.TabStableID
22+ PaneID domain.PaneStableID
23+ Attachment *attachedClient
24+ AttachmentCapability attachmentCapability
25+}
26+
9 func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {27 func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10 if d == nil || source.Session.ID == "" || source.TabID == "" {28 if d == nil || source.Session.ID == "" || source.TabID == "" {
11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)29 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
@@ -37,66 +55,22 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
37 return nil55 return nil
38 }56 }
39 57
40-type pickerIntent uint858+// commitMovePickerSelection performs the move the owning source authorised.
41-59+// beforeFollow runs once admission proves the move will follow; afterFollow
42-const (60+// receives the fresh effect on the published destination capability that the
43- pickerNavigate pickerIntent = iota61+// move postcommit admits, so a composite follow can report its completion
44- pickerMovePane62+// after the committing effect ended.
45- pickerMoveTab63+func (d *Daemon) commitMovePickerSelection(intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, beforeFollow func() error, afterFollow func(*attachmentEffect)) error {
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 {
93 destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}64 destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
94 switch intent {65 switch intent {
95- case pickerMovePane:66+ case protocol.PickerIntentMovePane:
96 if target.TabID == "" {67 if target.TabID == "" {
97 return errMovePaneInvalid68 return errMovePaneInvalid
98 }69 }
99 return d.movePane(movePaneRequest{70 return d.movePane(movePaneRequest{
71+ Follow: true,
72+ BeforeFollow: beforeFollow,
73+ AfterFollow: afterFollow,
100 Attachment: source.Attachment,74 Attachment: source.Attachment,
101 AttachmentCapability: source.AttachmentCapability,75 AttachmentCapability: source.AttachmentCapability,
102 Source: source.Session,76 Source: source.Session,
@@ -105,8 +79,11 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
105 Destination: destination,79 Destination: destination,
106 DestinationTabID: target.TabID,80 DestinationTabID: target.TabID,
107 })81 })
108- case pickerMoveTab:82+ case protocol.PickerIntentMoveTab:
109 return d.moveTab(moveTabRequest{83 return d.moveTab(moveTabRequest{
84+ Follow: true,
85+ BeforeFollow: beforeFollow,
86+ AfterFollow: afterFollow,
110 Attachment: source.Attachment,87 Attachment: source.Attachment,
111 AttachmentCapability: source.AttachmentCapability,88 AttachmentCapability: source.AttachmentCapability,
112 Source: source.Session,89 Source: source.Session,
@@ -118,7 +95,7 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
118 }95 }
119 }96 }
120 97
121-func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {98+func (d *Daemon) previewTarget(target picker.Target, intent protocol.PickerIntent) (*session, *tab) {
122 d.mu.Lock()99 d.mu.Lock()
123 sess := d.sessions[target.Session]100 sess := d.sessions[target.Session]
124 if sess == nil {101 if sess == nil {
@@ -131,7 +108,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
131 if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {108 if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) {
132 return nil, nil109 return nil, nil
133 }110 }
134- if intent == pickerMoveTab {111+ if intent == protocol.PickerIntentMoveTab {
135 if len(sess.tabs) == 0 {112 if len(sess.tabs) == 0 {
136 return nil, nil113 return nil, nil
137 }114 }
@@ -150,72 +127,3 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess
150 }127 }
151 return sess, sess.tabs[target.TabIndex]128 return sess, sess.tabs[target.TabIndex]
152 }129 }
153-
154-type pickerRefreshOptions struct {
155- // preserveSelection keeps the currently selected target selected across the
156- // rebuild even for navigate intent (used by the sort toggle).
157- preserveSelection bool
158- // nearestRow, when >= 0, selects the row occupying this index after the
159- // rebuild (used after deletes). -1 disables it.
160- nearestRow int
161-}
162-
163-func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164- sess := ac.currentSession()
165- if sess == nil {
166- return
167- }
168- rt := ac.overlays
169- rt.pickerMu.Lock()
170- if rt.picker == nil {
171- rt.pickerMu.Unlock()
172- return
173- }
174- intent, source := rt.pickerIntent, rt.pickerSource
175- observedModel, observedGeneration := rt.picker, rt.pickerGeneration
176- rt.pickerRefreshSequence++
177- refreshSequence := rt.pickerRefreshSequence
178- current := picker.SourceFilter{}
179- if intent != pickerNavigate || opts.preserveSelection {
180- cursor, _ := rt.picker.Cursor()
181- current = picker.SourceFilter{
182- Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
183- }
184- }
185- rt.pickerMu.Unlock()
186- model := d.newPickerModel(sess, ac, intent, source, current)
187- if rt.afterPickerRefreshBuild != nil {
188- rt.afterPickerRefreshBuild(model)
189- }
190- if intent != pickerNavigate {
191- if _, ok := model.Selected(); !ok {
192- d.closePickerIfCurrentRefresh(ac, observedModel, observedGeneration, refreshSequence)
193- return
194- }
195- }
196- rt.pickerMu.Lock()
197- updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source
198- var before, after picker.Target
199- var haveBefore, haveAfter bool
200- if updated {
201- before, haveBefore = rt.picker.Selected()
202- rt.picker.ReplaceFrom(model)
203- if intent == pickerNavigate && opts.nearestRow >= 0 {
204- rt.picker.SelectNearestRow(opts.nearestRow)
205- }
206- rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
207- after, haveAfter = rt.picker.Selected()
208- }
209- rt.pickerMu.Unlock()
210- if !updated {
211- return
212- }
213- // A directory publication rebuilds rows around the same selection:
214- // keep an in-flight remote preview when the exact selected route did
215- // not change instead of cancelling it on every revision. Availability
216- // reasons render around the route and are ignored here.
217- if haveBefore && haveAfter && pickerRouteTargetsEqual(before, after) {
218- return
219- }
220- d.registerPreviewForSelection(ac)
221-}
M internal/usecase/daemon/move_picker_test.go +508 -160
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "sync/atomic"
5 "testing"4 "testing"
6 "time"5 "time"
7 6
@@ -9,7 +8,8 @@ import (
9 8
10 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/usecase/picker"11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/wire"
13 )13 )
14 14
15 func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {15 func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
@@ -18,9 +18,24 @@ func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *
18 }18 }
19 19
20 func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {20 func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
21+ t.Helper()
22+ d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, clock, extraDestinationTabs)
23+ // The move suites drive typed interactions, so the daemon publishes offers
24+ // and snapshots to this attachment: drain them in the background so a
25+ // guarded send never blocks the fixture.
26+ go func() {
27+ for range sends {
28+ }
29+ }()
30+ return d, source, ac, destination, destinationTab, releases
31+}
32+
33+// setupMovePickerSessionsCore builds the move fixture without draining the
34+// attachment transport, so a regression can read exactly what the daemon sent.
35+func setupMovePickerSessionsCore(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func(), chan wire.Frame) {
21 t.Helper()36 t.Helper()
22 sourcePTY, releaseSource := newBlockingPTY(t)37 sourcePTY, releaseSource := newBlockingPTY(t)
23- d, source, ac, _ := newManualSessionWithPTYsClock(t, clock, sourcePTY)38+ d, source, ac, sends := newManualSessionWithPTYsClock(t, clock, sourcePTY)
24 source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}39 source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
25 d.mu.Lock()40 d.mu.Lock()
26 delete(d.sessions, domain.SessionID("manual"))41 delete(d.sessions, domain.SessionID("manual"))
@@ -48,7 +63,69 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
48 d.mu.Lock()63 d.mu.Lock()
49 d.sessions[destination.id] = destination64 d.sessions[destination.id] = destination
50 d.mu.Unlock()65 d.mu.Unlock()
51- return d, source, ac, destination, destinationTab, releases66+ return d, source, ac, destination, destinationTab, releases, sends
67+}
68+
69+// moveSourceForSession names the exact source a move command captures. The
70+// attachment carrying the command travels with it, exactly like the palette
71+// entry: the move transaction must freeze every other effect but this one.
72+func moveSourceForSession(sess *session, ac *attachedClient, tabID domain.TabStableID, paneID domain.PaneStableID) moveSourceLocator {
73+ return moveSourceLocator{
74+ Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
75+ TabID: tabID,
76+ PaneID: paneID,
77+ Attachment: ac,
78+ }
79+}
80+
81+// openMovePickerForTest opens one move interaction, exactly like the palette
82+// command does, and releases the effect that carried the offer: the
83+// interaction namespace lives on the attachment, and a later move must be able
84+// to freeze every other effect on the source.
85+func openMovePickerForTest(t *testing.T, d *Daemon, ac *attachedClient, sess *session, intent protocol.PickerIntent, source moveSourceLocator) {
86+ t.Helper()
87+ current := ac.transportSnapshot()
88+ _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport)
89+ if !admitted {
90+ // A test may deliberately drop the source attachment before opening;
91+ // the captured capability still admits the open.
92+ effect, admitted = ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
93+ }
94+ require.True(t, admitted)
95+ require.NoError(t, d.openPickerForAttachment(ac, effect, intent, source, 0))
96+ effect.End()
97+}
98+
99+// pickActionEffectForTest admits one effect for a typed action. A move drains
100+// every live effect on its source, so the caller ends it right before the
101+// commit, exactly like a client frame that has already been consumed.
102+func pickActionEffectForTest(t *testing.T, sess *session, ac *attachedClient) *attachmentEffect {
103+ t.Helper()
104+ effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
105+ require.True(t, admitted)
106+ return effect
107+}
108+
109+// moveSelectionForDestination builds the typed move the client would send for
110+// the destination session's first selectable line.
111+func moveSelectionForDestination(t *testing.T, ac *attachedClient, destination domain.SessionID) protocol.PickerSelection {
112+ t.Helper()
113+ ac.overlays.pickerMu.Lock()
114+ interaction := ac.overlays.pickerInteraction
115+ revision := ac.overlays.pickerRevisions[servingPickerSourceID]
116+ key := ""
117+ for candidate, target := range ac.overlays.pickerKeys {
118+ if target.Session == destination {
119+ key = candidate
120+ break
121+ }
122+ }
123+ ac.overlays.pickerMu.Unlock()
124+ require.NotEmpty(t, key, "no move destination resolves to session %s", destination)
125+ return protocol.PickerSelection{
126+ InteractionID: interaction, SourceID: servingPickerSourceID,
127+ SourceRevision: revision, Key: key, Action: protocol.PickerActionMove,
128+ }
52 }129 }
53 130
54 func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {131 func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
@@ -59,12 +136,13 @@ func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
59 require.True(t, ac.overlays.paletteActive())136 require.True(t, ac.overlays.paletteActive())
60 d.handleInput(source, ac, []byte("MFP\r"))137 d.handleInput(source, ac, []byte("MFP\r"))
61 require.False(t, ac.overlays.paletteActive())138 require.False(t, ac.overlays.paletteActive())
62- require.True(t, ac.overlays.pickerActive())
63 139
64 ac.overlays.pickerMu.Lock()140 ac.overlays.pickerMu.Lock()
65- intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource141+ open := ac.overlays.pickerOpen
142+ intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource
66 ac.overlays.pickerMu.Unlock()143 ac.overlays.pickerMu.Unlock()
67- require.Equal(t, pickerMovePane, intent)144+ require.True(t, open)
145+ require.Equal(t, protocol.PickerIntentMovePane, intent)
68 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)146 require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
69 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)147 require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
70 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)148 require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
@@ -79,12 +157,13 @@ func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
79 157
80 d.handleInput(source, ac, []byte("\x1b "))158 d.handleInput(source, ac, []byte("\x1b "))
81 d.handleInput(source, ac, []byte("MAT\r"))159 d.handleInput(source, ac, []byte("MAT\r"))
82- require.True(t, ac.overlays.pickerActive())
83 160
84 ac.overlays.pickerMu.Lock()161 ac.overlays.pickerMu.Lock()
85- intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerSource162+ open := ac.overlays.pickerOpen
163+ intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource
86 ac.overlays.pickerMu.Unlock()164 ac.overlays.pickerMu.Unlock()
87- require.Equal(t, pickerMoveTab, intent)165+ require.True(t, open)
166+ require.Equal(t, protocol.PickerIntentMoveTab, intent)
88 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)167 require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
89 require.Equal(t, domain.PaneStableID(""), captured.PaneID)168 require.Equal(t, domain.PaneStableID(""), captured.PaneID)
90 require.Same(t, ac, captured.Attachment)169 require.Same(t, ac, captured.Attachment)
@@ -100,116 +179,99 @@ func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
100 d.handleInput(source, ac, []byte("\x1b "))179 d.handleInput(source, ac, []byte("\x1b "))
101 d.handleInput(source, ac, []byte("MFP\r"))180 d.handleInput(source, ac, []byte("MFP\r"))
102 181
103- require.False(t, ac.overlays.pickerActive())182+ require.False(t, ac.overlays.pickerClientActive())
104 history := d.notices.history()183 history := d.notices.history()
105 require.NotEmpty(t, history)184 require.NotEmpty(t, history)
106 require.Equal(t, "No destination available.", history[0].Message)185 require.Equal(t, "No destination available.", history[0].Message)
107 }186 }
108 187
109-func TestMovePickerEnterCommitsMovePane(t *testing.T) {188+func TestMovePickerMoveCommitsPane(t *testing.T) {
110 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)189 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
111 defer releaseAll(releases)190 defer releaseAll(releases)
112- source.mu.Lock()
113- clearAttachmentsForTestLocked(source)
114- source.mu.Unlock()
115 sourceTab := source.tabs[0]191 sourceTab := source.tabs[0]
116 moved := sourceTab.focusedPane()192 moved := sourceTab.focusedPane()
117 193
118- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{194+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
119- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},195+ effect := pickActionEffectForTest(t, source, ac)
120- TabID: "source-tab", PaneID: "source-pane",196+ selection := moveSelectionForDestination(t, ac, destination.id)
121- }))197+ effect.End()
122- d.handlePickerInput(ac, []byte("\r"))198+ d.resolvePickerSelection(effect, selection)
123 199
124- require.False(t, ac.overlays.pickerActive())
125 require.Nil(t, source.tabs)200 require.Nil(t, source.tabs)
126 require.Same(t, moved, destinationTab.panes[moved.id])201 require.Same(t, moved, destinationTab.panes[moved.id])
127 require.Same(t, destination, moved.ownerSnapshot().session)202 require.Same(t, destination, moved.ownerSnapshot().session)
128 }203 }
129 204
130-func TestMovePickerEnterCommitsMovePaneViaSharedAPI(t *testing.T) {205+func TestMovePickerCommitMovePaneViaSharedAPI(t *testing.T) {
131 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)206 d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
132 defer releaseAll(releases)207 defer releaseAll(releases)
133- source.mu.Lock()
134- clearAttachmentsForTestLocked(source)
135- source.mu.Unlock()
136 sourceTab := source.tabs[0]208 sourceTab := source.tabs[0]
137 moved := sourceTab.focusedPane()209 moved := sourceTab.focusedPane()
138 210
139- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{211+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
140- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},212+ selection := moveSelectionForDestination(t, ac, destination.id)
141- TabID: "source-tab", PaneID: "source-pane",
142- }))
143 ac.overlays.pickerMu.Lock()213 ac.overlays.pickerMu.Lock()
144- target, ok := ac.overlays.picker.Selected()214+ target, ok := ac.overlays.pickerKeys[selection.Key]
145 ac.overlays.pickerMu.Unlock()215 ac.overlays.pickerMu.Unlock()
146 require.True(t, ok)216 require.True(t, ok)
147- require.NoError(t, d.commitMovePickerSelection(pickerMovePane, moveSourceLocator{217+ require.NoError(t, d.commitMovePickerSelection(protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"), target, nil, nil))
148- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},
149- TabID: "source-tab", PaneID: "source-pane",
150- }, target))
151 218
152 require.Nil(t, source.tabs)219 require.Nil(t, source.tabs)
153 require.Same(t, moved, destinationTab.panes[moved.id])220 require.Same(t, moved, destinationTab.panes[moved.id])
154 require.Same(t, destination, moved.ownerSnapshot().session)221 require.Same(t, destination, moved.ownerSnapshot().session)
155 }222 }
156 223
157-func TestMovePickerEnterCommitsMoveTab(t *testing.T) {224+func TestMovePickerMoveCommitsTab(t *testing.T) {
158 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)225 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
159 defer releaseAll(releases)226 defer releaseAll(releases)
160- source.mu.Lock()
161- clearAttachmentsForTestLocked(source)
162- source.mu.Unlock()
163 movedTab := source.tabs[0]227 movedTab := source.tabs[0]
164 movedTab.stableID = "moved-tab"228 movedTab.stableID = "moved-tab"
165 229
166- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{230+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))
167- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},231+ effect := pickActionEffectForTest(t, source, ac)
168- TabID: "moved-tab",232+ selection := moveSelectionForDestination(t, ac, destination.id)
169- }))233+ completed := make(chan struct{})
170- d.handlePickerInput(ac, []byte("\r"))234+ go func() {
235+ d.resolvePickerSelection(effect, selection)
236+ close(completed)
237+ }()
238+ awaitTestCompletion(t, completed, "move-tab picker selection deadlocked on its own attachment effect")
171 239
172- require.False(t, ac.overlays.pickerActive())
173 require.Nil(t, source.tabs)240 require.Nil(t, source.tabs)
174 require.Len(t, destination.tabs, 2)241 require.Len(t, destination.tabs, 2)
175 require.Same(t, movedTab, destination.tabs[1])242 require.Same(t, movedTab, destination.tabs[1])
176 }243 }
177 244
178-func TestMovePickerEscapePerformsNoMutation(t *testing.T) {245+func TestMovePickerCancelPerformsNoMutation(t *testing.T) {
179- clk := &signalClock{timers: make(chan *signalTimer, 16)}246+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
180- d, source, ac, _, _, releases := setupMovePickerSessionsWithClock(t, clk, 0)
181 defer releaseAll(releases)247 defer releaseAll(releases)
182 before := len(source.tabs)248 before := len(source.tabs)
183 249
184- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{250+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
185- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},251+ effect := pickActionEffectForTest(t, source, ac)
186- TabID: "source-tab", PaneID: "source-pane",252+ ac.overlays.pickerMu.Lock()
187- }))253+ interaction := ac.overlays.pickerInteraction
188- for len(clk.timers) > 0 {254+ ac.overlays.pickerMu.Unlock()
189- <-clk.timers255+ require.True(t, d.closePickerForAttachment(ac, effect, interaction))
190- }256+
191- d.handlePickerInput(ac, []byte("\x1b"))257+ require.False(t, ac.overlays.pickerClientActive())
192- timer := <-clk.timers
193- timer.ch <- time.Now()
194- require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
195 require.Len(t, source.tabs, before)258 require.Len(t, source.tabs, before)
196 }259 }
197 260
198-func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {261+func TestMovePickerStaleDestinationReportsNotice(t *testing.T) {
199 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)262 d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
200 defer releaseAll(releases)263 defer releaseAll(releases)
201 264
202- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{265+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
203- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},266+ effect := pickActionEffectForTest(t, source, ac)
204- TabID: "source-tab", PaneID: "source-pane",267+ selection := moveSelectionForDestination(t, ac, destination.id)
205- }))
206 d.mu.Lock()268 d.mu.Lock()
207 delete(d.sessions, destination.id)269 delete(d.sessions, destination.id)
208 d.mu.Unlock()270 d.mu.Unlock()
209 271
210- d.handlePickerInput(ac, []byte("\r"))272+ effect.End()
273+ d.resolvePickerSelection(effect, selection)
211 274
212- require.False(t, ac.overlays.pickerActive())
213 history := d.notices.history()275 history := d.notices.history()
214 require.NotEmpty(t, history)276 require.NotEmpty(t, history)
215 require.Equal(t, "Destination is no longer available.", history[0].Message)277 require.Equal(t, "Destination is no longer available.", history[0].Message)
@@ -217,147 +279,433 @@ func TestMovePickerStaleDestinationKeepsFeedback(t *testing.T) {
217 }279 }
218 280
219 func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {281 func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
220- d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)282+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
221 defer releaseAll(releases)283 defer releaseAll(releases)
222 sourceTab := source.tabs[0]284 sourceTab := source.tabs[0]
223 285
224- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{286+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
225- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},287+ effect := pickActionEffectForTest(t, source, ac)
226- TabID: "source-tab", PaneID: "source-pane",288+ selection := moveSelectionForDestination(t, ac, destination.id)
227- }))
228 sourceTab.mu.Lock()289 sourceTab.mu.Lock()
229 delete(sourceTab.panes, sourceTab.tree.Focus)290 delete(sourceTab.panes, sourceTab.tree.Focus)
230 sourceTab.mu.Unlock()291 sourceTab.mu.Unlock()
231 292
232- d.handlePickerInput(ac, []byte("\r"))293+ effect.End()
294+ d.resolvePickerSelection(effect, selection)
233 295
234- require.True(t, ac.overlays.pickerActive())296+ require.True(t, ac.overlays.pickerClientActive())
235 history := d.notices.history()297 history := d.notices.history()
236 require.NotEmpty(t, history)298 require.NotEmpty(t, history)
237 require.Equal(t, "Pane no longer exists.", history[0].Message)299 require.Equal(t, "Pane no longer exists.", history[0].Message)
238 }300 }
239 301
240 func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {302 func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
241- d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)303+ d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
242 defer releaseAll(releases)304 defer releaseAll(releases)
243 305
244- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMoveTab, moveSourceLocator{306+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "source-tab", ""))
245- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},307+ effect := pickActionEffectForTest(t, source, ac)
246- TabID: "source-tab",308+ selection := moveSelectionForDestination(t, ac, destination.id)
247- }))
248 source.mu.Lock()309 source.mu.Lock()
249 source.tabs = nil310 source.tabs = nil
250 source.mu.Unlock()311 source.mu.Unlock()
251 312
252- d.handlePickerInput(ac, []byte("\r"))313+ effect.End()
314+ d.resolvePickerSelection(effect, selection)
253 315
254- require.True(t, ac.overlays.pickerActive())316+ require.True(t, ac.overlays.pickerClientActive())
255 history := d.notices.history()317 history := d.notices.history()
256 require.NotEmpty(t, history)318 require.NotEmpty(t, history)
257 require.Equal(t, "Tab no longer exists.", history[0].Message)319 require.Equal(t, "Tab no longer exists.", history[0].Message)
258 }320 }
259 321
260-func TestMovePickerRefreshCloseKeepsReplacementPicker(t *testing.T) {322+// TestMovePickerSupersedingOpenRetiresTheEarlierNamespace pins that a second
261- d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)323+// open moves the interaction on, so rows published for the retired namespace
324+// can never be committed again.
325+func TestMovePickerSupersedingOpenRetiresTheEarlierNamespace(t *testing.T) {
326+ d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
262 defer releaseAll(releases)327 defer releaseAll(releases)
263- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{328+ sourceSnapshot := moveSourceForSession(source, ac, "source-tab", "source-pane")
264- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},329+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
265- TabID: "source-tab", PaneID: "source-pane",330+ effect := pickActionEffectForTest(t, source, ac)
266- }))331+ ac.overlays.pickerMu.Lock()
267- d.mu.Lock()332+ firstInteraction := ac.overlays.pickerInteraction
268- delete(d.sessions, destination.id)333+ firstRevision := ac.overlays.pickerRevisions[servingPickerSourceID]
269- d.mu.Unlock()334+ firstKey := ""
335+ for candidate := range ac.overlays.pickerKeys {
336+ firstKey = candidate
337+ break
338+ }
339+ ac.overlays.pickerMu.Unlock()
340+ require.NotEmpty(t, firstKey)
270 341
271- rebuildReached := make(chan struct{})342+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
272- allowClose := make(chan struct{})343+ ac.overlays.pickerMu.Lock()
273- refreshed := make(chan struct{})344+ secondInteraction := ac.overlays.pickerInteraction
274- ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {345+ ac.overlays.pickerMu.Unlock()
275- close(rebuildReached)346+ require.Greater(t, secondInteraction, firstInteraction)
276- <-allowClose347+
348+ // A commit from the retired namespace is rejected and leaves the open
349+ // interaction untouched.
350+ d.resolvePickerSelection(effect, protocol.PickerSelection{
351+ InteractionID: firstInteraction, SourceID: servingPickerSourceID,
352+ SourceRevision: firstRevision, Key: firstKey, Action: protocol.PickerActionMove,
353+ })
354+ ac.overlays.pickerMu.Lock()
355+ require.True(t, ac.overlays.pickerOpen)
356+ require.Equal(t, secondInteraction, ac.overlays.pickerInteraction)
357+ ac.overlays.pickerMu.Unlock()
358+}
359+
360+// Exercise the real selection admission (not a pre-ended test effect), and
361+// observe the publication boundary without sleeps or concurrent state reads.
362+func TestMovePickerCompositeFollow(t *testing.T) {
363+ for _, tt := range []struct {
364+ name string
365+ intent protocol.PickerIntent
366+ retainSource bool
367+ }{
368+ {"final-tab", protocol.PickerIntentMoveTab, false},
369+ {"retained-source-tab", protocol.PickerIntentMoveTab, true},
370+ {"final-pane", protocol.PickerIntentMovePane, false},
371+ } {
372+ t.Run(tt.name, func(t *testing.T) {
373+ d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
374+ defer releaseAll(releases)
375+ movedTab := source.tabs[0]
376+ if tt.retainSource {
377+ pty, release := newBlockingPTY(t)
378+ defer release()
379+ source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))
380+ publishTiledPaneOwners(source, source.tabs[1])
381+ }
382+ peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
383+ peer.setSession(source)
384+ source.registerAttachment(peer)
385+ destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
386+ destinationPeer.setSession(destination)
387+ destination.registerAttachment(destinationPeer)
388+ destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))
389+ old := captureAttachmentCapability(source, ac, ac.transport())
390+ locator := moveSourceForSession(source, ac, "source-tab", "")
391+ locator.AttachmentCapability = old
392+ if tt.intent == protocol.PickerIntentMovePane {
393+ locator.PaneID = "source-pane"
394+ }
395+ openMovePickerForTest(t, d, ac, source, tt.intent, locator)
396+ effect := pickActionEffectForTest(t, source, ac)
397+ selection := moveSelectionForDestination(t, ac, destination.id)
398+ closedAtCommit := false
399+ rebased := make(chan struct{}, 1)
400+ ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
401+ hook := func() { closedAtCommit = !ac.overlays.pickerClientActive() }
402+ d.beforeMoveTabCommit = hook
403+ d.beforeMovePaneCommit = hook
404+ done := make(chan struct{})
405+ go func() {
406+ d.resolvePickerSelection(effect, selection)
407+ close(done)
408+ }()
409+ awaitTestCompletion(t, done, "composite move waited on its own effect")
410+ require.True(t, closedAtCommit)
411+ select {
412+ case <-rebased:
413+ default:
414+ t.Fatal("composite move skipped transition first-paint rebase")
415+ }
416+ require.True(t, destination.attachmentRegistered(destinationPeer))
417+ require.Same(t, destinationTab, destination.tabForAttachment(destinationPeer))
418+ require.False(t, ac.overlays.pickerClientActive())
419+ require.Same(t, destination, ac.currentSession())
420+ require.True(t, destination.attachmentRegistered(ac))
421+ require.False(t, source.attachmentRegistered(ac))
422+ require.False(t, old.current())
423+ if tt.intent == protocol.PickerIntentMoveTab {
424+ require.Same(t, movedTab, destination.tabForAttachment(ac))
425+ } else {
426+ require.Same(t, destinationTab, destination.tabForAttachment(ac))
427+ }
428+ if tt.retainSource {
429+ require.True(t, source.attachmentRegistered(peer))
430+ require.Same(t, source, d.sessionByID(source.id))
431+ } else {
432+ require.False(t, source.attachmentRegistered(peer))
433+ require.Nil(t, d.sessionByID(source.id))
434+ }
435+ })
277 }436 }
437+}
438+
439+// A composite follow ends the committing effect so the move can drain its
440+// source. Its completion must still reach the client: the postcommit admits a
441+// fresh effect on the published destination capability and reports the
442+// correlated PickerResult the ui-driver action waits for.
443+func TestMovePickerCompositeFollowReportsCorrelatedResult(t *testing.T) {
444+ d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
445+ defer releaseAll(releases)
446+ old := captureAttachmentCapability(source, ac, ac.transport())
447+ locator := moveSourceForSession(source, ac, "source-tab", "")
448+ locator.AttachmentCapability = old
449+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
450+ effect := pickActionEffectForTest(t, source, ac)
451+ selection := moveSelectionForDestination(t, ac, destination.id)
452+ selection.CauseActionID = 4242
453+ done := make(chan struct{})
278 go func() {454 go func() {
279- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})455+ d.resolvePickerSelection(effect, selection)
280- close(refreshed)456+ close(done)
281 }()457 }()
282- select {458+ awaitTestCompletion(t, done, "composite follow never reported completion")
283- case <-rebuildReached:459+
284- case <-time.After(time.Second):460+ result := awaitSentPickerResult(t, sends)
285- t.Fatal("timed out waiting for move-picker refresh rebuild")461+ require.Equal(t, selection.CauseActionID, result.CauseActionID)
286- }462+ require.Equal(t, selection.InteractionID, result.InteractionID)
463+ require.Equal(t, selection.Key, result.Key)
464+ require.Equal(t, protocol.PickerActionMove, result.Action)
465+ assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once")
466+}
287 467
288- replacement := d.newPickerModel(source, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})468+// TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability pins the
289- d.publishPicker(source, ac, replacement, pickerNavigate, moveSourceLocator{})469+// non-admission window: when the exact capability the follow published stops
290- close(allowClose)470+// being admissible between its identity send and the postcommit completion (a
291- select {471+// concurrent link replacement while the attachment stays live), the completion
292- case <-refreshed:472+// must be re-admitted on the attachment's current capability and reported
293- case <-time.After(time.Second):473+// exactly once, instead of only logging while the ui-driver action runs to its
294- t.Fatal("timed out waiting for move-picker refresh")474+// deadline.
475+func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *testing.T) {
476+ d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
477+ defer releaseAll(releases)
478+ base, ok := ac.transport().(*mockServerConnection)
479+ require.True(t, ok, "fixture transport is not the capturing mock")
480+ old := captureAttachmentCapability(source, ac, ac.transport())
481+ locator := moveSourceForSession(source, ac, "source-tab", "")
482+ locator.AttachmentCapability = old
483+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
484+ effect := pickActionEffectForTest(t, source, ac)
485+ selection := moveSelectionForDestination(t, ac, destination.id)
486+ selection.CauseActionID = 5252
487+
488+ // Supersede the exact published capability after the follow's identity send
489+ // and first paint but before completion admission. The replacement keeps the
490+ // attachment live on its current session while retiring the published link.
491+ // A render effect may still be in flight, so this reproduces the transition's
492+ // end state (a newer published capability for the replacement link) rather
493+ // than requiring a drained gate.
494+ replaced := make(chan attachmentCapability, 1)
495+ d.beforeMoveFollowCompletion = func(published attachmentCapability) {
496+ supersedeAttachmentCapabilityForTest(published, base)
497+ replaced <- published
295 }498 }
296- ac.overlays.pickerMu.Lock()499+ done := make(chan struct{})
297- require.Same(t, replacement, ac.overlays.picker)500+ go func() {
298- ac.overlays.pickerMu.Unlock()501+ d.resolvePickerSelection(effect, selection)
502+ close(done)
503+ }()
504+ awaitTestCompletion(t, done, "composite follow never reported completion")
505+ published := <-replaced
506+ require.False(t, published.current(), "the superseded published capability was still admissible")
507+
508+ // The completion must arrive through the current capability, exactly once.
509+ result := awaitSentPickerResult(t, sends)
510+ require.Equal(t, selection.CauseActionID, result.CauseActionID)
511+ require.Equal(t, selection.InteractionID, result.InteractionID)
512+ require.Equal(t, protocol.PickerActionMove, result.Action)
513+ assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once")
299 }514 }
300 515
301-func TestMovePickerOlderEmptyRefreshCannotCloseNewerValidRefresh(t *testing.T) {516+// supersedeAttachmentCapabilityForTest retires an attachment's exact published
302- d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)517+// capability for a replacement link that keeps the attachment live, modelling a
303- defer releaseAll(releases)518+// concurrent reconnect or transition that won the gate after publication. It
304- require.NoError(t, d.enterPickerForIntent(source, ac, pickerMovePane, moveSourceLocator{519+// republishes the current capability directly so it also works while a render
305- Session: moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name},520+// effect may still be in flight.
306- TabID: "source-tab", PaneID: "source-pane",521+func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {
307- }))522+ wrapper := &pickerSnapshotFailTransport{mockServerConnection: base}
308- d.mu.Lock()523+ published.ac.replaceTransport(wrapper)
309- delete(d.sessions, destination.id)524+ current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)
310- d.mu.Unlock()525+ gate := &published.ac.lifecycle
526+ gate.mu.Lock()
527+ gate.initLocked()
528+ current.generation = gate.generation.Add(1)
529+ gate.capability = current
530+ gate.failedTransport = transportSnapshot{}
531+ gate.mu.Unlock()
532+}
311 533
312- rebuildReached := make(chan struct{})534+// TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity pins
313- allowOld := make(chan struct{})535+// the earlier non-admission window: when the exact capability the follow
314- var builds atomic.Int32536+// published is superseded before finishAttachmentTransition admits its
315- ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {537+// committed route identity (a concurrent link replacement while the attachment
316- if builds.Add(1) == 1 {538+// stays live), the transition must still send the committed identity, perform
317- close(rebuildReached)539+// the authoritative first paint, and report completion exactly once on the
318- select {540+// attachment's current capability. Aborting instead would leave the post-close
319- case <-allowOld:541+// drain and the ui-driver action pending forever.
320- case <-time.After(time.Second):542+func TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity(t *testing.T) {
321- t.Error("timed out waiting to release the older move-picker refresh")543+ d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
322- }544+ defer releaseAll(releases)
323- }545+ base, ok := ac.transport().(*mockServerConnection)
546+ require.True(t, ok, "fixture transport is not the capturing mock")
547+ // A published route ledger is what makes a ready transition owe its
548+ // committed identity to the client.
549+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
550+ old := captureAttachmentCapability(source, ac, ac.transport())
551+ locator := moveSourceForSession(source, ac, "source-tab", "")
552+ locator.AttachmentCapability = old
553+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
554+ effect := pickActionEffectForTest(t, source, ac)
555+ selection := moveSelectionForDestination(t, ac, destination.id)
556+ selection.CauseActionID = 6161
557+
558+ rebased := make(chan struct{}, 1)
559+ ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
560+
561+ // Supersede the published capability before the transition admits its
562+ // committed route identity, exactly in the window final review flagged.
563+ superseded := make(chan attachmentCapability, 1)
564+ d.beforeAttachmentTransitionIdentityAdmission = func(published attachmentCapability) {
565+ supersedeAttachmentCapabilityForTest(published, base)
566+ superseded <- published
324 }567 }
325- oldDone := make(chan struct{})568+ done := make(chan struct{})
326 go func() {569 go func() {
327- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})570+ d.resolvePickerSelection(effect, selection)
328- close(oldDone)571+ close(done)
329 }()572 }()
573+ awaitTestCompletion(t, done, "superseded composite follow never completed")
574+ published := <-superseded
575+ require.False(t, published.current(), "the superseded published capability was still admissible")
576+
330 select {577 select {
331- case <-rebuildReached:578+ case <-rebased:
332- case <-time.After(time.Second):579+ default:
333- t.Fatal("timed out waiting for the first move-picker refresh rebuild")580+ t.Fatal("superseded composite follow skipped its authoritative first paint")
334 }581 }
335 582
336- d.mu.Lock()583+ // The committed identity must reach the live replacement link for the
337- d.sessions[destination.id] = destination584+ // session the attachment actually holds, exactly once, and the correlated
338- d.mu.Unlock()585+ // completion must follow exactly once.
339- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})586+ identity := awaitSentCommittedIdentity(t, sends)
340- close(allowOld)587+ require.Equal(t, destination.name, identity.Target.SessionName)
341- select {588+ require.Equal(t, destination.incarnation, identity.Target.LifecycleID)
342- case <-oldDone:589+ result := awaitSentPickerResult(t, sends)
343- case <-time.After(time.Second):590+ require.Equal(t, selection.CauseActionID, result.CauseActionID)
344- t.Fatal("timed out waiting for the older move-picker refresh")591+ require.Equal(t, selection.InteractionID, result.InteractionID)
592+ require.Equal(t, protocol.PickerActionMove, result.Action)
593+ assertNoFurtherFrame(t, sends, wire.MsgCommittedRouteIdentity, "composite follow published its committed identity more than once")
594+ assertNoFurtherFrame(t, sends, wire.MsgPickerResult, "composite follow reported its result more than once")
595+}
596+
597+// awaitSentCommittedIdentity reads the next committed route identity the daemon
598+// published, skipping unrelated frames.
599+func awaitSentCommittedIdentity(t *testing.T, sends <-chan wire.Frame) protocol.CommittedRouteIdentity {
600+ t.Helper()
601+ deadline := time.After(2 * time.Second)
602+ for {
603+ select {
604+ case frame := <-sends:
605+ if frame.Type != wire.MsgCommittedRouteIdentity {
606+ continue
607+ }
608+ identity, err := wire.UnmarshalCommittedRouteIdentity(frame.Payload)
609+ require.NoError(t, err)
610+ return identity
611+ case <-deadline:
612+ t.Fatal("composite follow sent no committed route identity")
613+ return protocol.CommittedRouteIdentity{}
614+ }
345 }615 }
616+}
346 617
347- require.True(t, ac.overlays.pickerActive(), "the older empty refresh must not close the newer in-place publication")618+// assertNoFurtherFrame proves a frame was produced exactly once: no later frame
348- ac.overlays.pickerMu.Lock()619+// of that type may follow the one the caller already observed.
349- _, ok := ac.overlays.picker.Selected()620+func assertNoFurtherFrame(t *testing.T, sends <-chan wire.Frame, frameType wire.MsgType, failure string) {
350- ac.overlays.pickerMu.Unlock()621+ t.Helper()
351- require.True(t, ok)622+ deadline := time.After(200 * time.Millisecond)
623+ for {
624+ select {
625+ case frame := <-sends:
626+ require.NotEqual(t, frameType, frame.Type, failure)
627+ case <-deadline:
628+ return
629+ }
630+ }
352 }631 }
353 632
354-func TestMovePickerDispatchMuNotHeldWhileOpen(t *testing.T) {633+// A rejection after the picker closed must not touch the ended effect. It
355- d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)634+// delivers bounded feedback and a repaint through the attachment's current
635+// capability so the client's post-close drain can release the terminal.
636+func TestMovePickerClosedRejectionUsesFreshEffect(t *testing.T) {
637+ d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
356 defer releaseAll(releases)638 defer releaseAll(releases)
639+ old := captureAttachmentCapability(source, ac, ac.transport())
640+ locator := moveSourceForSession(source, ac, "source-tab", "")
641+ locator.AttachmentCapability = old
642+ openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
643+ effect := pickActionEffectForTest(t, source, ac)
644+ // Publication must reject after the picker closed: changing the destination
645+ // tab geometry re-admits the already-validated topology as stale.
646+ d.afterMoveTabSourceSnapshot = func() {
647+ destinationTab.mu.Lock()
648+ destinationTab.size = domain.Size{Cols: 79, Rows: 22}
649+ destinationTab.mu.Unlock()
650+ }
651+ selection := moveSelectionForDestination(t, ac, destination.id)
652+ selection.CauseActionID = 99
653+ done := make(chan struct{})
654+ go func() {
655+ d.resolvePickerSelection(effect, selection)
656+ close(done)
657+ }()
658+ awaitTestCompletion(t, done, "closed rejection never returned")
357 659
358- d.handleInput(source, ac, []byte("\x1b "))660+ failure, result := collectSentPickerOutcome(t, sends)
359- d.handleInput(source, ac, []byte("MFP\r"))661+ require.Nil(t, result, "rejected composite move reported success")
360- require.True(t, ac.overlays.pickerActive())662+ require.Equal(t, selection.CauseActionID, failure.CauseActionID)
361- require.True(t, source.dispatchMu.TryLock(), "dispatchMu must not remain held while the move picker is open")663+ require.Equal(t, protocol.PickerActionFailed, failure.Code)
362- source.dispatchMu.Unlock()664+}
665+
666+// awaitSentPickerResult blocks until the daemon reports a completed mutation.
667+func awaitSentPickerResult(t *testing.T, sends <-chan wire.Frame) protocol.PickerResult {
668+ t.Helper()
669+ deadline := time.After(2 * time.Second)
670+ for {
671+ select {
672+ case frame := <-sends:
673+ if frame.Type != wire.MsgPickerResult {
674+ continue
675+ }
676+ result, err := wire.UnmarshalPickerResult(frame.Payload)
677+ require.NoError(t, err)
678+ return result
679+ case <-deadline:
680+ t.Fatal("composite follow sent no correlated PickerResult")
681+ return protocol.PickerResult{}
682+ }
683+ }
684+}
685+
686+// collectSentPickerOutcome drains the bounded set of frames one rejection can
687+// produce and returns the PickerFailure, plus any PickerResult that must not
688+// exist on the rejection path.
689+func collectSentPickerOutcome(t *testing.T, sends <-chan wire.Frame) (failure protocol.PickerFailure, result *protocol.PickerResult) {
690+ t.Helper()
691+ found := false
692+ deadline := time.After(500 * time.Millisecond)
693+ for {
694+ select {
695+ case frame := <-sends:
696+ switch frame.Type {
697+ case wire.MsgPickerFailure:
698+ decoded, err := wire.UnmarshalPickerFailure(frame.Payload)
699+ require.NoError(t, err)
700+ failure, found = decoded, true
701+ case wire.MsgPickerResult:
702+ decoded, err := wire.UnmarshalPickerResult(frame.Payload)
703+ require.NoError(t, err)
704+ result = &decoded
705+ }
706+ case <-deadline:
707+ require.True(t, found, "closed rejection sent no bounded failure")
708+ return failure, result
709+ }
710+ }
363 }711 }
M internal/usecase/daemon/move_postcommit.go +41 -2
@@ -20,6 +20,8 @@ func firstMovePane(panes []*pane) *pane {
20 }20 }
21 21
22 type movePostcommitPlan struct {22 type movePostcommitPlan struct {
23+ followResult attachmentTransitionResult
24+ afterFollow func(*attachmentEffect)
23 source *session25 source *session
24 destination *session26 destination *session
25 sourceName string27 sourceName string
@@ -82,6 +84,45 @@ func (p movePostcommitPlan) execute(d *Daemon) {
82 }84 }
83 p.unlockDispatch()85 p.unlockDispatch()
84 p.reservation.Release()86 p.reservation.Release()
87+ if p.followResult.published.ac != nil {
88+ result, err := d.finishAttachmentTransition(attachmentTransitionRequest{ready: true}, p.followResult)
89+ if err != nil {
90+ d.log.Warn("composite move follow route identity failed",
91+ "err", err, "operation", p.operation,
92+ "source_session", p.sourceName, "destination_session", p.destinationName)
93+ } else {
94+ d.deferAttachmentTransitionCleanups(result)
95+ d.firstPaintForTransition(result.published)
96+ if p.afterFollow != nil {
97+ if d.beforeMoveFollowCompletion != nil {
98+ d.beforeMoveFollowCompletion(result.published)
99+ }
100+ // The committing effect ended so the transaction could freeze
101+ // admission. Report completion from a fresh effect on the exact
102+ // capability the follow published on the destination.
103+ effect, admitted := result.published.ac.beginAttachmentEffect(result.published)
104+ if !admitted {
105+ // The exact published capability stopped being admissible
106+ // between the follow's identity send and this completion. That
107+ // is either a concurrent transition superseding the capability
108+ // while the attachment stays live, or the attachment being torn
109+ // down. Re-admit only the attachment's current capability so the
110+ // pending client action is completed exactly once through the
111+ // existing admission seam. A detached attachment admits nothing,
112+ // so its teardown resolves the pending action; no stale
113+ // capability or retired transport is reused.
114+ _, effect, admitted = admitCurrentAttachmentCapabilityEffect(result.published)
115+ }
116+ if admitted {
117+ p.afterFollow(effect)
118+ effect.End()
119+ } else {
120+ d.log.Warn("composite move follow completion not admitted",
121+ "operation", p.operation, "destination_session", p.destinationName)
122+ }
123+ }
124+ }
125+ }
85 attrs := []any{126 attrs := []any{
86 "operation", p.operation,127 "operation", p.operation,
87 "source_session", p.sourceName,128 "source_session", p.sourceName,
@@ -143,7 +184,6 @@ func (p movePostcommitPlan) execute(d *Daemon) {
143 )184 )
144 }185 }
145 for _, attachment := range p.retiredAttachments {186 for _, attachment := range p.retiredAttachments {
146- d.unregisterPreview(attachment.ac)
147 attachment.ac.clearCaptureFrames()187 attachment.ac.clearCaptureFrames()
148 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {188 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
149 d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName)189 d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName)
@@ -154,7 +194,6 @@ func (p movePostcommitPlan) execute(d *Daemon) {
154 if retirement.parked == nil || retirement.parked.ac == nil {194 if retirement.parked == nil || retirement.parked.ac == nil {
155 continue195 continue
156 }196 }
157- d.unregisterPreview(retirement.parked.ac)
158 retirement.parked.ac.clearCaptureFrames()197 retirement.parked.ac.clearCaptureFrames()
159 }198 }
160 d.finishParkedAttachmentRetirements(p.retiredParked)199 d.finishParkedAttachmentRetirements(p.retiredParked)
M internal/usecase/daemon/move_tab.go +3 -0
@@ -42,6 +42,9 @@ func (d *Daemon) moveTab(req moveTabRequest) error {
42 func (p *moveTabTopology) transactionRequest() moveTransactionRequest {42 func (p *moveTabTopology) transactionRequest() moveTransactionRequest {
43 req := p.req43 req := p.req
44 return moveTransactionRequest{44 return moveTransactionRequest{
45+ follow: req.Follow,
46+ beforeFollow: req.BeforeFollow,
47+ afterFollow: req.AfterFollow,
45 operation: "tab",48 operation: "tab",
46 attachment: req.Attachment,49 attachment: req.Attachment,
47 attachmentCapability: req.AttachmentCapability,50 attachmentCapability: req.AttachmentCapability,
M internal/usecase/daemon/move_transaction.go +59 -4
@@ -21,6 +21,9 @@ type moveTopology interface {
21 }21 }
22 22
23 type moveTransactionRequest struct {23 type moveTransactionRequest struct {
24+ follow bool
25+ beforeFollow func() error
26+ afterFollow func(*attachmentEffect)
24 operation string27 operation string
25 attachment *attachedClient28 attachment *attachedClient
26 attachmentCapability attachmentCapability29 attachmentCapability attachmentCapability
@@ -57,6 +60,8 @@ type moveTransaction struct {
57 frozenEffects attachmentTransitionGuard60 frozenEffects attachmentTransitionGuard
58 effectsFrozen bool61 effectsFrozen bool
59 62
63+ followResult attachmentTransitionResult
64+
60 publication moveTopologyPublication65 publication moveTopologyPublication
61 publishErr error66 publishErr error
62 67
@@ -121,6 +126,11 @@ func (d *Daemon) executeMove(topology moveTopology) (result error) {
121 return err126 return err
122 }127 }
123 t.topology.afterAdmission(d)128 t.topology.afterAdmission(d)
129+ if t.follows() && t.request.beforeFollow != nil {
130+ if err := t.request.beforeFollow(); err != nil {
131+ return err
132+ }
133+ }
124 if err := t.freezeSourceAttachments(); err != nil {134 if err := t.freezeSourceAttachments(); err != nil {
125 return err135 return err
126 }136 }
@@ -210,11 +220,18 @@ func (t *moveTransaction) admit() error {
210 // attachment lifecycle Module. Move uses nonblocking acquisition to preserve220 // attachment lifecycle Module. Move uses nonblocking acquisition to preserve
211 // its ordering with final teardown.221 // its ordering with final teardown.
212 func (t *moveTransaction) freezeSourceAttachments() error {222 func (t *moveTransaction) freezeSourceAttachments() error {
213- if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 {223+ if (!t.topology.willRetireSource() && !t.follows()) || len(t.sourceAttachments) == 0 {
214 return nil224 return nil
215 }225 }
216- interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments))226+ participants := t.sourceAttachments
217- for _, attachment := range t.sourceAttachments {227+ if !t.topology.willRetireSource() {
228+ participants = []*attachedClient{t.request.attachment}
229+ }
230+ interrupts := make([]attachmentTransportInterrupt, 0, len(participants))
231+ for _, attachment := range participants {
232+ if t.follows() && attachment == t.request.attachment {
233+ continue // The following connection must remain usable for first paint.
234+ }
218 if transport := t.sourceTransports[attachment]; transport.transport != nil {235 if transport := t.sourceTransports[attachment]; transport.transport != nil {
219 interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})236 interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
220 }237 }
@@ -227,7 +244,7 @@ func (t *moveTransaction) freezeSourceAttachments() error {
227 t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)244 t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
228 }245 }
229 },246 },
230- }, t.sourceAttachments...)247+ }, participants...)
231 if !frozen.acquired || !frozen.drained {248 if !frozen.acquired || !frozen.drained {
232 frozen.unfreeze()249 frozen.unfreeze()
233 return errMovePaneInvalid250 return errMovePaneInvalid
@@ -260,11 +277,40 @@ func (t *moveTransaction) publishLocked() error {
260 return errMoveStaleTarget277 return errMoveStaleTarget
261 }278 }
262 279
280+ var follow *attachmentPublication
281+ if t.follows() {
282+ req := attachmentTransitionRequest{
283+ source: t.source, target: t.destination, next: t.request.attachment,
284+ expectedTransport: t.sourceTransports[t.request.attachment],
285+ preflighted: true, attachmentEffectsFrozen: true, ready: true,
286+ }
287+ if t.request.attachmentCapability.ac != nil {
288+ req.sourceCapability = &t.request.attachmentCapability
289+ }
290+ var err error
291+ follow, err = d.validateAttachmentTransitionPrelocked(req)
292+ if err != nil {
293+ return errMoveStaleTarget
294+ }
295+ // Topology sync migration takes coordinator locks itself. Session locks
296+ // and the frozen gate keep membership/lease authority pinned meanwhile.
297+ follow.unlockCoordinators()
298+ }
263 publication, err := t.topology.publishLocked(d, t.source, t.destination)299 publication, err := t.topology.publishLocked(d, t.source, t.destination)
264 if err != nil {300 if err != nil {
265 return err301 return err
266 }302 }
267 t.publication = publication303 t.publication = publication
304+ if follow != nil {
305+ follow.releaseCoordinators = lockAttachmentCoordinators(t.source, follow.sourceCoordinator, t.destination, follow.targetCoordinator)
306+ follow.req.activateTargetTab = true
307+ follow.req.targetTabIndex = indexMoveTabLocked(t.destination, publication.destinationTab)
308+ t.followResult = d.publishAttachmentTransitionPrelocked(follow)
309+ follow.unlockCoordinators()
310+ if t.source != t.destination && !publication.sourceEmpty {
311+ t.followResult.sourceGeometrySession = t.source
312+ }
313+ }
268 if publication.sourceEmpty {314 if publication.sourceEmpty {
269 d.unregisterSessionLocked(t.source)315 d.unregisterSessionLocked(t.source)
270 t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)316 t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
@@ -288,6 +334,8 @@ func (t *moveTransaction) publishLocked() error {
288 func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {334 func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
289 publication := t.publication335 publication := t.publication
290 return movePostcommitPlan{336 return movePostcommitPlan{
337+ followResult: t.followResult,
338+ afterFollow: t.request.afterFollow,
291 source: t.source,339 source: t.source,
292 destination: t.destination,340 destination: t.destination,
293 sourceName: t.sourceName,341 sourceName: t.sourceName,
@@ -314,3 +362,10 @@ func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *mov
314 destinationMetadataValid: t.destMetadataOK,362 destinationMetadataValid: t.destMetadataOK,
315 }363 }
316 }364 }
365+
366+// Picker pane moves follow only when their source retires; tab moves always
367+// follow. Command/API moves retain their existing topology-only semantics.
368+func (t *moveTransaction) follows() bool {
369+ return t.request.follow && t.request.attachment != nil &&
370+ (t.request.operation == "tab" || t.topology.willRetireSource())
371+}
M internal/usecase/daemon/navigation_attachment_handoff_test.go +1 -1
@@ -23,7 +23,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
23 {23 {
24 name: "picker selection", releasedAction: "detach",24 name: "picker selection", releasedAction: "detach",
25 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {25 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
26- d.enterPicker(source, ac)26+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
27 return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")27 return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
28 },28 },
29 },29 },
M internal/usecase/daemon/overlay_runtime.go +47 -44
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "context"
5 "sync"4 "sync"
6 "time"5 "time"
7 6
@@ -19,28 +18,45 @@ type overlayRuntime struct {
19 ac *attachedClient18 ac *attachedClient
20 19
21 pickerMu sync.Mutex20 pickerMu sync.Mutex
22- picker *picker.Model21+ // pickerPublishMu serializes snapshot publication for this attachment. A
23- // pickerGeneration identifies one open lifecycle. It advances only when a22+ // publisher reserves the sole right to publish, builds, validates, and
24- // picker is published, so delayed close and registration work can prove it23+ // sends its snapshot, and only then commits the revision it announced. It
25- // still owns the exact lifecycle it captured.24+ // is held across the transport send so two concurrent publishers cannot
26- pickerGeneration uint6425+ // interleave or reorder authoritative revisions. pickerMu is still the
27- // pickerRefreshSequence advances for every rebuild attempt in one open26+ // state lock and is never held across the send; the lock order is
28- // lifecycle. A zero close token deliberately disables this extra fence.27+ // pickerPublishMu -> pickerMu, and it is never taken while sendMu is held.
29- pickerRefreshSequence uint6428+ pickerPublishMu sync.Mutex
30- pickerTitle string29+ // picker* carries the serving-daemon side of the picker interaction,
31- pickerIntent pickerIntent30+ // guarded by pickerMu. Open tracks the current interaction namespace;
32- pickerSource moveSourceLocator31+ // interaction is the latest admitted open ID (late selections for closed
33- pickerPreview *tab32+ // interactions reject); revisions are the per-source published versions
34- pickerPreviewSession *session33+ // (older or duplicate revisions discard); keys maps opaque row keys of the
35- pickerRemotePreview picker.Preview34+ // serving source to resolved targets; intent and moverSource are the
36- pickerRemotePreviewCancel context.CancelFunc35+ // daemon-side facts captured at open; requestID echoes a client PickerBegin.
37- pickerPreviewGeneration uint6436+ pickerOpen bool
38- pickerPending []byte37+ pickerInteraction uint64
39- pickerESC pendingByteTimer38+ pickerRevisions map[string]uint64
40-39+ pickerKeys map[string]picker.Target
41- // Test-only, unsynchronized lifecycle seams. Assign them before picker40+ // pickerSourcePublished distinguishes "never published" from "published an
42- // publication or goroutine startup.41+ // empty set" for the current interaction. pickerRecent and pickerGrouped
43- afterPickerRefreshBuild func(*picker.Model)42+ // are the exact projections last published: a refresh whose projections
43+ // equal them is not authority-relevant, so it must not bump the revision
44+ // nor send a snapshot the client never needed to see.
45+ pickerSourcePublished bool
46+ pickerRecent protocol.PickerProjection
47+ pickerGrouped protocol.PickerProjection
48+ pickerIntent protocol.PickerIntent
49+ pickerMoveSource moveSourceLocator
50+ pickerRequestID uint64
51+ // pickerPreview* track the row the client asked to preview. The generation
52+ // supersedes an in-flight capture and names the render subscription, so a
53+ // delayed preview can never replace the row the user is displaying.
54+ // pickerPreviewSession pins the recorded subscription to the coordinator
55+ // that owns it (the target session, or the viewer for a remote row), so
56+ // teardown removes the exact target subscription.
57+ pickerPreviewGeneration uint64
58+ pickerPreviewKey string
59+ pickerPreviewSession *session
44 60
45 paletteMu sync.Mutex61 paletteMu sync.Mutex
46 palette *palette.Model62 palette *palette.Model
@@ -136,7 +152,7 @@ func (rt *overlayRuntime) Active() bool {
136 if rt == nil || rt.ac == nil {152 if rt == nil || rt.ac == nil {
137 return false153 return false
138 }154 }
139- return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()155+ return rt.promptActive() || rt.paletteActive() || rt.pickerClientActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive()
140 }156 }
141 157
142 func (rt *overlayRuntime) promptActive() bool {158 func (rt *overlayRuntime) promptActive() bool {
@@ -157,13 +173,18 @@ func (rt *overlayRuntime) paletteActive() bool {
157 return rt.palette != nil173 return rt.palette != nil
158 }174 }
159 175
160-func (rt *overlayRuntime) pickerActive() bool {176+// pickerClientActive reports whether a picker interaction is open on this
177+// attachment. Such an interaction owns the user input: the daemon must not
178+// route raw key or mouse events to the session while it is open. Its typed
179+// PickerSelection/PickerClose messages are the only effect it may have, and
180+// they arrive on the control path, not here.
181+func (rt *overlayRuntime) pickerClientActive() bool {
161 if rt == nil {182 if rt == nil {
162 return false183 return false
163 }184 }
164 rt.pickerMu.Lock()185 rt.pickerMu.Lock()
165 defer rt.pickerMu.Unlock()186 defer rt.pickerMu.Unlock()
166- return rt.picker != nil187+ return rt.pickerOpen
167 }188 }
168 189
169 func (rt *overlayRuntime) noticesActive() bool {190 func (rt *overlayRuntime) noticesActive() bool {
@@ -282,10 +303,6 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach
282 d.handlePaletteInput(ac, data, effect)303 d.handlePaletteInput(ac, data, effect)
283 return true304 return true
284 }305 }
285- if rt.pickerActive() {
286- d.handlePickerInput(ac, data, effect)
287- return true
288- }
289 if rt.noticesActive() {306 if rt.noticesActive() {
290 d.handleNoticesInput(ac, data)307 d.handleNoticesInput(ac, data)
291 return true308 return true
@@ -311,12 +328,6 @@ type overlayRenderSnapshot struct {
311 statusFeedback string328 statusFeedback string
312 resizeActive bool329 resizeActive bool
313 330
314- pickerActive bool
315- pickerModel *picker.Model
316- pickerTitle string
317- previewTab *tab
318- remotePreview picker.Preview
319-
320 noticesOverlayActive bool331 noticesOverlayActive bool
321 noticesOverlayModel *notices.Model332 noticesOverlayModel *notices.Model
322 333
@@ -383,14 +394,6 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
383 snap.resizeActive = rt.resizeActive394 snap.resizeActive = rt.resizeActive
384 rt.resizeMu.Unlock()395 rt.resizeMu.Unlock()
385 396
386- rt.pickerMu.Lock()
387- snap.pickerActive = rt.picker != nil
388- snap.pickerModel = rt.picker.Clone()
389- snap.pickerTitle = rt.pickerTitle
390- snap.previewTab = rt.pickerPreview
391- snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview)
392- rt.pickerMu.Unlock()
393-
394 rt.paletteMu.Lock()397 rt.paletteMu.Lock()
395 snap.paletteModel = rt.palette398 snap.paletteModel = rt.palette
396 snap.paletteActive = snap.paletteModel != nil399 snap.paletteActive = snap.paletteModel != nil
M internal/usecase/daemon/overlay_runtime_test.go +2 -22
@@ -31,11 +31,10 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
31 check func(t *testing.T, ac *attachedClient)31 check func(t *testing.T, ac *attachedClient)
32 }{32 }{
33 {33 {
34- name: "prompt before palette picker copy",34+ name: "prompt before palette copy",
35 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {35 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
36 t.Helper()36 t.Helper()
37 d.enterCopyMode(sess, ac)37 d.enterCopyMode(sess, ac)
38- d.enterPicker(sess, ac)
39 d.enterPalette(sess, ac)38 d.enterPalette(sess, ac)
40 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })39 d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
41 },40 },
@@ -44,39 +43,20 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
44 t.Helper()43 t.Helper()
45 require.Equal(t, "x", ac.overlays.prompt.Value())44 require.Equal(t, "x", ac.overlays.prompt.Value())
46 require.Equal(t, "", ac.overlays.palette.Query())45 require.Equal(t, "", ac.overlays.palette.Query())
47- require.True(t, ac.overlays.pickerActive())
48 require.True(t, ac.overlays.copyActive())46 require.True(t, ac.overlays.copyActive())
49 },47 },
50 },48 },
51 {49 {
52- name: "palette before picker copy",50+ name: "palette before copy",
53 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {51 setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
54 t.Helper()52 t.Helper()
55 d.enterCopyMode(sess, ac)53 d.enterCopyMode(sess, ac)
56- d.enterPicker(sess, ac)
57 d.enterPalette(sess, ac)54 d.enterPalette(sess, ac)
58 },55 },
59 input: []byte("x"),56 input: []byte("x"),
60 check: func(t *testing.T, ac *attachedClient) {57 check: func(t *testing.T, ac *attachedClient) {
61 t.Helper()58 t.Helper()
62 require.Equal(t, "x", ac.overlays.palette.Query())59 require.Equal(t, "x", ac.overlays.palette.Query())
63- require.True(t, ac.overlays.pickerActive())
64- require.True(t, ac.overlays.copyActive())
65- },
66- },
67- {
68- name: "picker before copy",
69- setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
70- t.Helper()
71- d.enterCopyMode(sess, ac)
72- d.enterPicker(sess, ac)
73- },
74- input: []byte("j"),
75- check: func(t *testing.T, ac *attachedClient) {
76- t.Helper()
77- target, ok := ac.overlays.picker.Selected()
78- require.True(t, ok)
79- require.Equal(t, 1, target.TabIndex)
80 require.True(t, ac.overlays.copyActive())60 require.True(t, ac.overlays.copyActive())
81 },61 },
82 },62 },
M internal/usecase/daemon/palette.go +64 -6
@@ -737,6 +737,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {737 if !attachmentHandoff && !d.closeExecutedPalette(ac, effect, generation, rawQuery) {
738 return738 return
739 }739 }
740+ // A client-owned picker may acquire presentation as soon as its offer's
741+ // output barrier is displayed. Commit the palette-free base first so that
742+ // barrier can never name a frame which still contains the command palette.
743+ if cmd.Slug == "session-picker" {
744+ d.invalidateRenderNow(sess, ac, true, "palette.go:session-picker-base")
745+ }
740 sess.dispatchMu.Lock()746 sess.dispatchMu.Lock()
741 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)747 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
742 sess.dispatchMu.Unlock()748 sess.dispatchMu.Unlock()
@@ -1046,7 +1052,7 @@ func (e paletteExec) OpenMovePanePicker() error {
1046 if target.tab == nil || target.pane == nil {1052 if target.tab == nil || target.pane == nil {
1047 return errMovePaneInvalid1053 return errMovePaneInvalid
1048 }1054 }
1049- return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{1055+ return e.openMovePicker(protocol.PickerIntentMovePane, moveSourceLocator{
1050 Session: sessionMoveLocator(e.sess),1056 Session: sessionMoveLocator(e.sess),
1051 TabID: domain.TabStableID(target.tab.stableID),1057 TabID: domain.TabStableID(target.tab.stableID),
1052 PaneID: domain.PaneStableID(target.pane.stableID),1058 PaneID: domain.PaneStableID(target.pane.stableID),
@@ -1059,13 +1065,33 @@ func (e paletteExec) OpenMoveTabPicker() error {
1059 if target.tab == nil {1065 if target.tab == nil {
1060 return errMovePaneInvalid1066 return errMovePaneInvalid
1061 }1067 }
1062- return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{1068+ return e.openMovePicker(protocol.PickerIntentMoveTab, moveSourceLocator{
1063 Session: sessionMoveLocator(e.sess),1069 Session: sessionMoveLocator(e.sess),
1064 TabID: domain.TabStableID(target.tab.stableID),1070 TabID: domain.TabStableID(target.tab.stableID),
1065 Attachment: e.ac,1071 Attachment: e.ac,
1066 })1072 })
1067 }1073 }
1068 1074
1075+// openMovePicker opens one move picker on an admitted effect. The captured
1076+// source is daemon-side state: it is bound to the interaction at open and
1077+// revalidated at commit, never supplied by the presenting client.
1078+func (e paletteExec) openMovePicker(intent protocol.PickerIntent, source moveSourceLocator) error {
1079+ if e.ac == nil || e.ac.overlays == nil {
1080+ return errAttachmentTransition
1081+ }
1082+ effect := e.effect
1083+ if effect == nil {
1084+ current := e.ac.transportSnapshot()
1085+ _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1086+ if !admitted {
1087+ return errAttachmentTransition
1088+ }
1089+ defer fresh.End()
1090+ effect = fresh
1091+ }
1092+ return e.d.openPickerForAttachment(e.ac, effect, intent, source, 0)
1093+}
1094+
1069 func (e paletteExec) focus(direction layout.Direction) error {1095 func (e paletteExec) focus(direction layout.Direction) error {
1070 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})1096 return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
1071 }1097 }
@@ -1173,11 +1199,43 @@ func (e paletteExec) RenameTabTo(name string) error {
1173 }1199 }
1174 1200
1175 func (e paletteExec) OpenSessionPicker() error {1201 func (e paletteExec) OpenSessionPicker() error {
1176- if e.ac != nil && e.ac.navigationCapabilities&protocol.NavigationCapabilityHomePicker != 0 && e.effect != nil {1202+ if e.ac == nil || e.ac.overlays == nil {
1177- return e.d.sendNavigationActionForAttachment(e.effect, protocol.NavigationOpenHomePicker)1203+ return errAttachmentTransition
1178 }1204 }
1179- e.d.enterPicker(e.sess, e.ac)1205+ // The palette already closed on execute (see handlePaletteInput):
1180- return nil1206+ // only invalidate when the close actually changed overlay state, then
1207+ // open the picker interaction on a freshly admitted effect so the
1208+ // opener's fence retires on the authoritative repaint below.
1209+ overlays := e.ac.overlays
1210+ overlays.paletteMu.Lock()
1211+ generation := overlays.paletteGeneration
1212+ query := ""
1213+ if overlays.palette != nil {
1214+ query = overlays.palette.Query()
1215+ }
1216+ overlays.paletteMu.Unlock()
1217+ if e.d.closeExecutedPalette(e.ac, e.effect, generation, query) {
1218+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go:session-picker")
1219+ }
1220+ effect := e.effect
1221+ if effect == nil {
1222+ current := e.ac.transportSnapshot()
1223+ _, fresh, admitted := e.ac.beginCurrentAttachmentEffect(e.sess, current.transport)
1224+ if !admitted {
1225+ return errAttachmentTransition
1226+ }
1227+ defer fresh.End()
1228+ effect = fresh
1229+ } else {
1230+ fresh, admitted := e.ac.beginAttachmentEffect(effect.capability())
1231+ if !admitted {
1232+ return errAttachmentTransition
1233+ }
1234+ fresh.uiActionID = effect.uiActionID
1235+ defer fresh.End()
1236+ effect = fresh
1237+ }
1238+ return e.d.openPickerForAttachment(e.ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0)
1181 }1239 }
1182 1240
1183 func (e paletteExec) OpenNotifications() error {1241 func (e paletteExec) OpenNotifications() error {
M internal/usecase/daemon/palette_test.go +19 -7
@@ -771,20 +771,26 @@ func TestRemoteDirectoryTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
771 d := newRemotePickerDaemon()771 d := newRemotePickerDaemon()
772 seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work")))772 seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work")))
773 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")773 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
774+ effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
775+ require.True(t, admitted)
776+ t.Cleanup(effect.End)
774 777
775 // Opening overlays builds from the latest snapshot without requesting778 // Opening overlays builds from the latest snapshot without requesting
776 // reconciliation; closing one overlay leaves the other intact.779 // reconciliation; closing one overlay leaves the other intact.
777- d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})780+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
778 d.enterPalette(sess, ac)781 d.enterPalette(sess, ac)
779- require.True(t, ac.overlays.pickerActive())782+ require.True(t, ac.overlays.pickerClientActive())
780 require.True(t, ac.overlays.paletteActive())783 require.True(t, ac.overlays.paletteActive())
781 784
782 d.closePalette(ac)785 d.closePalette(ac)
783 require.False(t, ac.overlays.paletteActive())786 require.False(t, ac.overlays.paletteActive())
784- require.True(t, ac.overlays.pickerActive(), "closing the palette must not disturb the open picker")787+ require.True(t, ac.overlays.pickerClientActive(), "closing the palette must not disturb the open picker")
785 788
786- d.closePicker(ac)789+ ac.overlays.pickerMu.Lock()
787- require.False(t, ac.overlays.pickerActive())790+ interaction := ac.overlays.pickerInteraction
791+ ac.overlays.pickerMu.Unlock()
792+ require.True(t, d.closePickerForAttachment(ac, effect, interaction))
793+ require.False(t, ac.overlays.pickerClientActive())
788 }794 }
789 795
790 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {796 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
@@ -1573,8 +1579,14 @@ func TestPaletteExecMethods(t *testing.T) {
1573 d.closePrompt(ac)1579 d.closePrompt(ac)
1574 require.True(t, sess.ephemeral)1580 require.True(t, sess.ephemeral)
1575 require.NoError(t, exec.OpenSessionPicker())1581 require.NoError(t, exec.OpenSessionPicker())
1576- require.True(t, ac.overlays.pickerActive())1582+ require.True(t, ac.overlays.pickerClientActive())
1577- d.closePicker(ac)1583+ ac.overlays.pickerMu.Lock()
1584+ interaction := ac.overlays.pickerInteraction
1585+ ac.overlays.pickerMu.Unlock()
1586+ effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
1587+ require.True(t, admitted)
1588+ require.True(t, d.closePickerForAttachment(ac, effect, interaction))
1589+ effect.End()
1578 require.NoError(t, exec.EnterVisualMode())1590 require.NoError(t, exec.EnterVisualMode())
1579 require.True(t, ac.overlays.copyActive())1591 require.True(t, ac.overlays.copyActive())
1580 d.exitCopyMode(ac)1592 d.exitCopyMode(ac)
D internal/usecase/daemon/parked_route.go +0 -494
@@ -1,494 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "crypto/rand"
6- "io"
7- "time"
8-
9- "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol"
12-)
13-
14-const parkedRouteLeaseTTL = 15 * time.Minute
15-
16-type parkedRouteLease struct {
17- id protocol.ParkedRouteLeaseID
18- generation uint64
19- transport transportSnapshot
20- expiresAt time.Time
21- timer ports.Timer
22- stop chan struct{}
23- suspended bool
24- inFlight bool
25- expired bool
26-}
27-
28-func (lease *parkedRouteLease) matches(id protocol.ParkedRouteLeaseID, generation uint64, transport transportSnapshot) bool {
29- return lease != nil && lease.id == id && lease.generation == generation && lease.transport == transport
30-}
31-
32-func newParkedRouteLeaseID() (protocol.ParkedRouteLeaseID, error) {
33- var id protocol.ParkedRouteLeaseID
34- if _, err := io.ReadFull(rand.Reader, id[:]); err != nil {
35- return protocol.ParkedRouteLeaseID{}, err
36- }
37- return id, nil
38-}
39-
40-func stopParkedRouteLeaseLocked(lease *parkedRouteLease) {
41- if lease == nil {
42- return
43- }
44- if lease.timer != nil {
45- lease.timer.Stop()
46- lease.timer = nil
47- }
48- if lease.stop != nil {
49- close(lease.stop)
50- lease.stop = nil
51- }
52-}
53-
54-func (d *Daemon) armParkedRoute(effect *attachmentEffect) (protocol.ParkedRouteLeaseID, error) {
55- if !effect.current() {
56- return protocol.ParkedRouteLeaseID{}, errAttachmentTransition
57- }
58- ac := effect.ac
59- if ac == nil || ac.navigationCapabilities&protocol.NavigationCapabilityHomePicker == 0 {
60- return protocol.ParkedRouteLeaseID{}, errAttachmentTransition
61- }
62- id, err := newParkedRouteLeaseID()
63- if err != nil {
64- return protocol.ParkedRouteLeaseID{}, err
65- }
66- lease := &parkedRouteLease{
67- id: id, generation: effect.generation, transport: effect.transport,
68- expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),
69- timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),
70- }
71- ac.parkedRouteMu.Lock()
72- stopParkedRouteLeaseLocked(ac.parkedRoute)
73- ac.parkedRoute = lease
74- ac.parkedRouteOutput.Store(false)
75- ac.parkedRouteFullPending.Store(false)
76- timer, stop := lease.timer, lease.stop
77- ac.parkedRouteMu.Unlock()
78- go d.watchParkedRouteLease(ac, lease, timer, stop)
79- return id, nil
80-}
81-
82-func (d *Daemon) watchParkedRouteLease(ac *attachedClient, lease *parkedRouteLease, timer ports.Timer, stop <-chan struct{}) {
83- select {
84- case <-timer.C():
85- d.expireParkedRoute(ac, lease)
86- case <-stop:
87- }
88-}
89-
90-func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease) {
91- if ac == nil || lease == nil {
92- return
93- }
94- ac.parkedRouteMu.Lock()
95- if ac.parkedRoute != lease {
96- ac.parkedRouteMu.Unlock()
97- return
98- }
99- lease.timer = nil
100- lease.stop = nil
101- if lease.inFlight {
102- lease.expired = true
103- ac.parkedRouteMu.Unlock()
104- return
105- }
106- ac.parkedRoute = nil
107- ac.parkedRouteOutput.Store(false)
108- ac.parkedRouteFullPending.Store(false)
109- ac.parkedRouteMu.Unlock()
110- if ac.lifecycle.generationValue() == lease.generation && ac.transportSnapshotCurrent(lease.transport) {
111- _ = ac.closeCapturedTransport(lease.transport.transport)
112- }
113-}
114-
115-func (ac *attachedClient) clearParkedRoute() {
116- if ac == nil {
117- return
118- }
119- ac.parkedRouteMu.Lock()
120- stopParkedRouteLeaseLocked(ac.parkedRoute)
121- ac.parkedRoute = nil
122- ac.parkedRouteOutput.Store(false)
123- ac.parkedRouteFullPending.Store(false)
124- ac.parkedRouteMu.Unlock()
125-}
126-
127-func (ac *attachedClient) parkedRouteStatus(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time, suspended bool) protocol.ParkedRouteStatus {
128- if ac == nil || !token.current() {
129- return protocol.ParkedRouteRejected
130- }
131- ac.parkedRouteMu.Lock()
132- defer ac.parkedRouteMu.Unlock()
133- lease := ac.parkedRoute
134- if !lease.matches(id, token.generation, token.transport) {
135- return protocol.ParkedRouteRejected
136- }
137- if !now.Before(lease.expiresAt) {
138- stopParkedRouteLeaseLocked(lease)
139- ac.parkedRoute = nil
140- ac.parkedRouteOutput.Store(false)
141- ac.parkedRouteFullPending.Store(false)
142- return protocol.ParkedRouteExpired
143- }
144- if lease.suspended != suspended {
145- return protocol.ParkedRouteRejected
146- }
147- return 0
148-}
149-
150-func (ac *attachedClient) prepareParkedRoute(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus {
151- if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {
152- return status
153- }
154- ac.parkedRouteMu.Lock()
155- defer ac.parkedRouteMu.Unlock()
156- lease := ac.parkedRoute
157- if !lease.matches(id, token.generation, token.transport) || lease.suspended {
158- return protocol.ParkedRouteRejected
159- }
160- lease.suspended = true
161- ac.parkedRouteOutput.Store(true)
162- return 0
163-}
164-
165-func (ac *attachedClient) consumeParkedRoute(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus {
166- if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {
167- return status
168- }
169- ac.parkedRouteMu.Lock()
170- defer ac.parkedRouteMu.Unlock()
171- lease := ac.parkedRoute
172- if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
173- return protocol.ParkedRouteRejected
174- }
175- stopParkedRouteLeaseLocked(lease)
176- ac.parkedRoute = nil
177- return 0
178-}
179-
180-func (ac *attachedClient) beginParkedRouteSwitch(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus {
181- if ac == nil || !token.current() {
182- return protocol.ParkedRouteRejected
183- }
184- ac.parkedRouteMu.Lock()
185- defer ac.parkedRouteMu.Unlock()
186- lease := ac.parkedRoute
187- if !lease.matches(id, token.generation, token.transport) || !lease.suspended || lease.inFlight {
188- return protocol.ParkedRouteRejected
189- }
190- if lease.expired || !now.Before(lease.expiresAt) {
191- stopParkedRouteLeaseLocked(lease)
192- ac.parkedRoute = nil
193- ac.parkedRouteOutput.Store(false)
194- ac.parkedRouteFullPending.Store(false)
195- return protocol.ParkedRouteExpired
196- }
197- lease.inFlight = true
198- return 0
199-}
200-
201-func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentCapability, id protocol.ParkedRouteLeaseID, now time.Time) protocol.ParkedRouteStatus {
202- if ac == nil {
203- return protocol.ParkedRouteRejected
204- }
205- ac.parkedRouteMu.Lock()
206- defer ac.parkedRouteMu.Unlock()
207- lease := ac.parkedRoute
208- if !lease.matches(id, token.generation, token.transport) || !lease.inFlight {
209- return protocol.ParkedRouteRejected
210- }
211- lease.inFlight = false
212- if lease.expired || !now.Before(lease.expiresAt) {
213- stopParkedRouteLeaseLocked(lease)
214- ac.parkedRoute = nil
215- ac.parkedRouteOutput.Store(false)
216- ac.parkedRouteFullPending.Store(false)
217- return protocol.ParkedRouteExpired
218- }
219- return 0
220-}
221-
222-func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentCapability, id protocol.ParkedRouteLeaseID) protocol.ParkedRouteStatus {
223- if ac == nil || published.ac != ac || !published.current() {
224- return protocol.ParkedRouteRejected
225- }
226- ac.parkedRouteMu.Lock()
227- defer ac.parkedRouteMu.Unlock()
228- lease := ac.parkedRoute
229- if !lease.matches(id, previous.generation, previous.transport) || !lease.suspended || !lease.inFlight {
230- return protocol.ParkedRouteRejected
231- }
232- // Expiry is checked when the request is admitted. Once publication starts,
233- // the accepted request must commit atomically instead of turning a completed
234- // identity publication into a late pre-commit rejection.
235- stopParkedRouteLeaseLocked(lease)
236- ac.parkedRoute = nil
237- return 0
238-}
239-
240-func (d *Daemon) sendParkedRouteResponse(effect *attachmentEffect, response protocol.ParkedRouteResponse) error {
241- return effect.sendControl(response)
242-}
243-
244-// sendParkedRouteResponseLocked preserves response -> full-output ordering.
245-// Caller holds ac.sendMu and keeps parkedRouteOutput set through this send.
246-func (d *Daemon) sendParkedRouteResponseLocked(ac *attachedClient, expected transportSnapshot, response protocol.ParkedRouteResponse) error {
247- if ac == nil || !ac.transportSnapshotCurrent(expected) || expected.transport == nil {
248- return errAttachmentTransition
249- }
250- return expected.transport.SendServer(response)
251-}
252-
253-func (ac *attachedClient) releaseParkedOutputLocked() {
254- ac.rebaseOutput()
255- ac.pipelineCache = composeCacheInput{}
256- ac.pipelineScratch = composeCacheInput{}
257- ac.parkedRouteFullPending.Store(true)
258- ac.parkedRouteOutput.Store(false)
259-}
260-
261-func (d *Daemon) parkedRouteResponseFailed(effect *attachmentEffect) {
262- if effect == nil {
263- return
264- }
265- capability := effect.capability()
266- launch := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport)
267- effect.End()
268- launch()
269-}
270-
271-func (d *Daemon) respondParkedRoute(effect *attachmentEffect, response protocol.ParkedRouteResponse) bool {
272- if effect == nil || effect.ac == nil {
273- return false
274- }
275- sender := effect
276- owned := false
277- if !sender.current() {
278- var admitted bool
279- sender, admitted = effect.ac.beginAttachmentEffect(effect.capability())
280- if !admitted {
281- return false
282- }
283- owned = true
284- }
285- if owned {
286- defer sender.End()
287- }
288- if err := d.sendParkedRouteResponse(sender, response); err != nil {
289- d.parkedRouteResponseFailed(sender)
290- return false
291- }
292- return true
293-}
294-
295-func (d *Daemon) handleParkedRouteRequest(effect *attachmentEffect, request protocol.ParkedRouteRequest) {
296- if protocol.ValidateParkedRouteRequest(request) != nil || !effect.current() || effect.ac == nil {
297- return
298- }
299- capability := effect.capability()
300- switch request.Action {
301- case protocol.ParkedRoutePrepare:
302- status := effect.ac.prepareParkedRoute(capability, request.LeaseID, d.clock.Now())
303- if status == 0 {
304- status = protocol.ParkedRouteReady
305- }
306- d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
307- case protocol.ParkedRouteResume:
308- d.resumeParkedRoute(effect, request)
309- case protocol.ParkedRouteSwitch:
310- d.switchParkedRoute(effect, request)
311- }
312-}
313-
314-func (d *Daemon) resumeParkedRoute(effect *attachmentEffect, request protocol.ParkedRouteRequest) {
315- capability := effect.capability()
316- status := effect.ac.consumeParkedRoute(capability, request.LeaseID, d.clock.Now())
317- if status != 0 {
318- d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
319- return
320- }
321- ac := effect.ac
322- ac.sendMu.Lock()
323- err := d.sendParkedRouteResponseLocked(ac, effect.transport, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: protocol.ParkedRouteResumed})
324- if err == nil {
325- ac.releaseParkedOutputLocked()
326- }
327- ac.sendMu.Unlock()
328- if err != nil {
329- d.parkedRouteResponseFailed(effect)
330- return
331- }
332- if effect.sess != nil {
333- d.firstPaintWithLease(effect.sess, ac, effect.lease, true)
334- }
335-}
336-
337-func (d *Daemon) rejectParkedRouteSwitch(effect *attachmentEffect, request protocol.ParkedRouteRequest, fallback protocol.ParkedRouteStatus) {
338- capability := effect.capability()
339- status := effect.ac.rejectParkedRouteSwitch(capability, request.LeaseID, d.clock.Now())
340- if status == 0 {
341- status = fallback
342- }
343- if d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == protocol.ParkedRouteExpired {
344- _ = effect.ac.closeCapturedTransport(effect.transport.transport)
345- }
346-}
347-
348-func (d *Daemon) switchParkedRoute(effect *attachmentEffect, request protocol.ParkedRouteRequest) {
349- capability := effect.capability()
350- if status := effect.ac.beginParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()); status != 0 {
351- if d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == protocol.ParkedRouteExpired {
352- _ = effect.ac.closeCapturedTransport(effect.transport.transport)
353- }
354- return
355- }
356- target := *request.Target
357- ctx := d.serveCtx
358- if ctx == nil {
359- ctx = context.Background()
360- }
361- if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {
362- d.rejectParkedRouteSwitch(effect, request, protocol.ParkedRouteStaleTarget)
363- return
364- }
365-
366- transition, targetSession, err := d.transitionParkedRouteTarget(effect, target)
367- if err != nil {
368- d.rejectParkedRouteSwitch(effect, request, protocol.ParkedRouteStaleTarget)
369- return
370- }
371- if status := effect.ac.consumePublishedParkedRoute(capability, transition.published, request.LeaseID); status != 0 {
372- d.abortPublishedAttachmentTransition(transition)
373- return
374- }
375- fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
376- if !admitted {
377- d.abortPublishedAttachmentTransition(transition)
378- return
379- }
380- freshCapability := fresh.capability()
381- ac := effect.ac
382- ac.sendMu.Lock()
383- responseErr := d.sendParkedRouteResponseLocked(ac, freshCapability.transport, protocol.ParkedRouteResponse{RequestID: request.RequestID, Status: protocol.ParkedRouteSwitched})
384- if responseErr == nil {
385- ac.releaseParkedOutputLocked()
386- }
387- ac.sendMu.Unlock()
388- if responseErr != nil {
389- launch := d.reserveAttachmentSendErrorCleanup(freshCapability, freshCapability.transport.transport)
390- fresh.End()
391- launch()
392- d.deferAttachmentTransitionCleanups(transition)
393- return
394- }
395- fresh.End()
396- d.touchMRU(targetSession)
397- d.deferAttachmentTransitionCleanups(transition)
398- transition.published.rebase = false
399- d.firstPaintForTransition(transition.published)
400-}
401-
402-func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string) error {
403- ctx, cancel := context.WithCancel(parent)
404- timer := d.clock.NewTimer(protocol.HandshakeTimeout)
405- stop := make(chan struct{})
406- go func() {
407- select {
408- case <-timer.C():
409- cancel()
410- case <-ctx.Done():
411- case <-stop:
412- }
413- }()
414- err := d.waitForTargetRestore(ctx, name)
415- close(stop)
416- timer.Stop()
417- cancel()
418- return err
419-}
420-
421-func (d *Daemon) transitionParkedRouteTarget(effect *attachmentEffect, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) {
422- capability := effect.capability()
423- d.mu.Lock()
424- if d.closing {
425- d.mu.Unlock()
426- return attachmentTransitionResult{}, nil, errAttachmentTransition
427- }
428- if live := d.findByNameLocked(target.SessionName); live != nil {
429- if live.incarnation != target.LifecycleID {
430- d.mu.Unlock()
431- return attachmentTransitionResult{}, nil, errAttachmentTransition
432- }
433- tabIndex, ok := remoteTargetTabIndexLocked(live, target)
434- if !ok {
435- d.mu.Unlock()
436- return attachmentTransitionResult{}, nil, errAttachmentTransition
437- }
438- live.mu.Lock()
439- tabID := domain.TabStableID(live.tabs[tabIndex].stableID)
440- live.mu.Unlock()
441- d.mu.Unlock()
442- transition, err := d.transitionAttachment(attachmentTransitionRequest{
443- source: effect.sess, target: live, next: effect.ac,
444- expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-switch",
445- expectedTargetLifecycle: &attachmentLifecycleFence{
446- name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,
447- tabID: tabID, tabIndex: tabIndex, checkTab: true,
448- },
449- activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
450- })
451- return transition, live, err
452- }
453-
454- inactive, ok := d.inactive[target.SessionName]
455- if !ok || !target.Stopped || inactive.incarnation != target.LifecycleID || !inactive.canResume() {
456- d.mu.Unlock()
457- return attachmentTransitionResult{}, nil, errAttachmentTransition
458- }
459- tabIndex, ok := remoteTargetTabIndexInactive(inactive, target)
460- if !ok {
461- d.mu.Unlock()
462- return attachmentTransitionResult{}, nil, errAttachmentTransition
463- }
464- d.mu.Unlock()
465-
466- var created *session
467- transition, err := d.transitionAttachment(attachmentTransitionRequest{
468- source: effect.sess, next: effect.ac,
469- expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-restore",
470- activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
471- createTargetLocked: func() (*session, error) {
472- current, ok := d.inactive[target.SessionName]
473- if !ok || current.incarnation != target.LifecycleID || !current.canResume() {
474- return nil, errAttachmentTransition
475- }
476- resolved, ok := remoteTargetTabIndexInactive(current, target)
477- if !ok || resolved != tabIndex {
478- return nil, errAttachmentTransition
479- }
480- env := copyEnvironment(d.baseEnv)
481- cwd := d.dirOrHome(current.cwd)
482- var createErr error
483- created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, effect.ac.geometrySnapshot(), env, current)
484- return created, createErr
485- },
486- })
487- if err != nil && created != nil {
488- _ = d.killSession(created, protocol.ReasonSessionKilled, true)
489- }
490- if err != nil {
491- return attachmentTransitionResult{}, created, err
492- }
493- return transition, created, nil
494-}
D internal/usecase/daemon/parked_route_test.go +0 -391
@@ -1,391 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "io"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/stretchr/testify/mock"
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- portsmocks "github.com/bnema/vev/internal/ports/mocks"
16- "github.com/bnema/vev/internal/protocol"
17- "github.com/bnema/vev/internal/protocol/wire"
18-)
19-
20-type parkedRouteFailTransport struct{}
21-
22-func (parkedRouteFailTransport) Send(wire.Frame) error { return errors.New("send failed") }
23-func (parkedRouteFailTransport) Recv() (wire.Frame, error) { return wire.Frame{}, io.EOF }
24-func (parkedRouteFailTransport) Close() error { return nil }
25-
26-type parkedRouteExpiryTransport struct {
27- sends chan wire.Frame
28- closed chan struct{}
29- once sync.Once
30-}
31-
32-func (t *parkedRouteExpiryTransport) Send(frame wire.Frame) error {
33- t.sends <- frame
34- return nil
35-}
36-
37-func (t *parkedRouteExpiryTransport) Recv() (wire.Frame, error) {
38- <-t.closed
39- return wire.Frame{}, io.EOF
40-}
41-
42-func (t *parkedRouteExpiryTransport) Close() error {
43- t.once.Do(func() { close(t.closed) })
44- return nil
45-}
46-
47-type parkedRouteClock struct {
48- mu sync.Mutex
49- now time.Time
50-}
51-
52-func (c *parkedRouteClock) Now() time.Time {
53- c.mu.Lock()
54- defer c.mu.Unlock()
55- return c.now
56-}
57-
58-func (c *parkedRouteClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
59-
60-func (c *parkedRouteClock) setNow(now time.Time) {
61- c.mu.Lock()
62- c.now = now
63- c.mu.Unlock()
64-}
65-
66-func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan wire.Frame) protocol.ParkedRouteLeaseID {
67- t.Helper()
68- ac.navigationCapabilities |= protocol.NavigationCapabilityHomePicker
69- token := source.captureAttachmentCapability(ac, ac.transport())
70- effect, admitted := ac.beginAttachmentEffect(token)
71- require.True(t, admitted)
72- require.NoError(t, d.sendNavigationActionForAttachment(effect, protocol.NavigationOpenHomePicker))
73- effect.End()
74- directiveFrame := awaitFrame(t, sends, wire.MsgNavigationAction)
75- directive, err := wire.UnmarshalNavigationDirective(directiveFrame.Payload)
76- require.NoError(t, err)
77-
78- prepare := protocol.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: protocol.ParkedRoutePrepare}
79- require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(prepare)}))
80- readyFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
81- ready, err := wire.UnmarshalParkedRouteResponse(readyFrame.Payload)
82- require.NoError(t, err)
83- require.Equal(t, protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady}, ready)
84- return directive.LeaseID
85-}
86-
87-func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) {
88- d, source, ac, sends, releases := newManualTabSession(t, 1)
89- defer releaseAll(releases)
90- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
91-
92- targetLifecycle := domain.SessionLifecycleID{7}
93- target := &session{
94- sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
95- ctx: source.ctx, cancel: func() {},
96- tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
97- }
98- publishTiledPaneOwners(target, target.tabs[0])
99- d.mu.Lock()
100- d.sessions[target.id] = target
101- d.mu.Unlock()
102- ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
103-
104- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
105- require.True(t, ac.parkedRouteOutput.Load())
106-
107- targetRequest := domain.RemoteSessionTarget{
108- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
109- SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
110- }
111- switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &targetRequest}
112- require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)}))
113-
114- require.Same(t, target, ac.currentAttachmentSession())
115- require.False(t, ac.parkedRouteOutput.Load())
116- responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
117- response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload)
118- require.NoError(t, err)
119- require.Equal(t, protocol.ParkedRouteResponse{RequestID: 2, Status: protocol.ParkedRouteSwitched}, response)
120-}
121-
122-func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.T) {
123- d, source, ac, sends, releases := newManualTabSession(t, 1)
124- defer releaseAll(releases)
125- clock := &parkedRouteClock{}
126- d.clock = clock
127- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
128-
129- targetLifecycle := domain.SessionLifecycleID{7}
130- target := &session{
131- sessionCore: sessionCore{id: "target", name: "target", incarnation: targetLifecycle, attachments: make(map[*attachedClient]struct{})},
132- ctx: source.ctx, cancel: func() {},
133- tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
134- }
135- publishTiledPaneOwners(target, target.tabs[0])
136- d.mu.Lock()
137- d.sessions[target.id] = target
138- d.mu.Unlock()
139- ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
140-
141- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
142- d.afterAttachmentEffectsFrozen = func() {
143- clock.setNow(time.Time{}.Add(parkedRouteLeaseTTL))
144- }
145-
146- targetRequest := domain.RemoteSessionTarget{
147- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: targetLifecycle,
148- SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
149- }
150- switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &targetRequest}
151- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)})
152-
153- require.Same(t, target, ac.currentAttachmentSession())
154- responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
155- response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload)
156- require.NoError(t, err)
157- require.Equal(t, protocol.ParkedRouteSwitched, response.Status)
158-}
159-
160-func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T) {
161- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
162- d.baseEnv = []string{"OWNER=daemon"}
163- source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
164- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
165- source.mu.Lock()
166- source.env = []string{"OWNER=source"}
167- source.mu.Unlock()
168-
169- lifecycle := domain.SessionLifecycleID{8}
170- d.mu.Lock()
171- d.inactive["stopped"] = inactiveSession{
172- name: "stopped", cwd: "/remote/work", incarnation: lifecycle, state: protocol.SessionDown,
173- tabNames: []string{"alpha", "beta"},
174- tabRecords: []domain.CatalogueTabRecord{
175- {StableID: "tab-a", Name: "alpha"},
176- {StableID: "tab-b", Name: "beta"},
177- },
178- }
179- d.mu.Unlock()
180- ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
181-
182- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
183-
184- target := domain.RemoteSessionTarget{
185- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: lifecycle,
186- SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),
187- }
188- switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &target}
189- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)})
190-
191- restored := ac.currentAttachmentSession()
192- require.NotNil(t, restored)
193- require.Equal(t, "stopped", restored.name)
194- require.Equal(t, lifecycle, restored.incarnation)
195- require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID)
196- restored.mu.Lock()
197- require.Equal(t, []string{"OWNER=daemon"}, restored.env)
198- restored.mu.Unlock()
199-}
200-
201-func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
202- d, source, ac, sends, releases := newManualTabSession(t, 1)
203- defer releaseAll(releases)
204- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
205- ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
206-
207- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
208-
209- missing := domain.RemoteSessionTarget{
210- Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{9},
211- SessionName: "missing", LiveTabID: "tab-1",
212- }
213- switchRequest := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch, Target: &missing}
214- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(switchRequest)})
215- staleFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
216- stale, err := wire.UnmarshalParkedRouteResponse(staleFrame.Payload)
217- require.NoError(t, err)
218- require.Equal(t, protocol.ParkedRouteStaleTarget, stale.Status)
219- require.Same(t, source, ac.currentAttachmentSession())
220- require.True(t, ac.parkedRouteOutput.Load())
221-
222- resume := protocol.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: protocol.ParkedRouteResume}
223- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)})
224- resumedFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
225- resumed, err := wire.UnmarshalParkedRouteResponse(resumedFrame.Payload)
226- require.NoError(t, err)
227- require.Equal(t, protocol.ParkedRouteResumed, resumed.Status)
228- require.False(t, ac.parkedRouteOutput.Load())
229- outputFrame := awaitFrame(t, sends, wire.MsgOutput)
230- output, err := wire.UnmarshalOutput(outputFrame.Payload)
231- require.NoError(t, err)
232- require.True(t, output.Full)
233-}
234-
235-func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
236- d, source, ac, sends, releases := newManualTabSession(t, 1)
237- defer releaseAll(releases)
238- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
239-
240- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
241- require.NoError(t, d.boundedSendOutputErr(ac, []byte("one-shot effect")))
242- select {
243- case frame := <-sends:
244- t.Fatalf("parked one-shot effect emitted frame type %d", frame.Type)
245- default:
246- }
247-
248- resume := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteResume}
249- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)})
250- _ = awaitFrame(t, sends, wire.MsgParkedRouteResponse)
251- outputFrame := awaitFrame(t, sends, wire.MsgOutput)
252- output, err := wire.UnmarshalOutput(outputFrame.Payload)
253- require.NoError(t, err)
254- require.True(t, output.Full)
255- require.NotContains(t, string(output.Data), "one-shot effect")
256- position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload)
257- require.NoError(t, err)
258- require.Equal(t, source.incarnation, position.Target.LifecycleID)
259- select {
260- case frame := <-sends:
261- t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type)
262- default:
263- }
264-}
265-
266-func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) {
267- d, source, ac, sends, releases := newManualTabSession(t, 1)
268- defer releaseAll(releases)
269- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
270-
271- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
272-
273- ac.parkedRouteMu.Lock()
274- ac.parkedRoute.expiresAt = d.clock.Now()
275- ac.parkedRouteMu.Unlock()
276- resume := protocol.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: protocol.ParkedRouteResume}
277- d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), wire.Frame{Type: wire.MsgParkedRouteRequest, Payload: wire.MarshalParkedRouteRequest(resume)})
278- responseFrame := awaitFrame(t, sends, wire.MsgParkedRouteResponse)
279- response, err := wire.UnmarshalParkedRouteResponse(responseFrame.Payload)
280- require.NoError(t, err)
281- require.Equal(t, protocol.ParkedRouteExpired, response.Status)
282- require.Same(t, source, ac.currentAttachmentSession())
283- require.False(t, ac.parkedRouteOutput.Load())
284-}
285-
286-func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) {
287- d, source, ac, sends, releases := newManualTabSession(t, 1)
288- defer releaseAll(releases)
289- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
290- leaseID := armAndPrepareParkedRoute(t, d, source, ac, sends)
291-
292- ac.parkedRouteMu.Lock()
293- ac.parkedRoute.inFlight = true
294- ac.parkedRouteMu.Unlock()
295- status := ac.consumeParkedRoute(source.captureAttachmentCapability(ac, ac.transport()), leaseID, d.clock.Now())
296-
297- require.Equal(t, protocol.ParkedRouteRejected, status)
298- ac.parkedRouteMu.Lock()
299- require.NotNil(t, ac.parkedRoute)
300- ac.parkedRouteMu.Unlock()
301- require.True(t, ac.parkedRouteOutput.Load())
302-}
303-
304-func TestParkedRouteLeaseExpiryClosesExactRetainedTransport(t *testing.T) {
305- d, source, ac, _, releases := newManualTabSession(t, 1)
306- defer releaseAll(releases)
307- expiry := make(chan time.Time, 1)
308- timer := portsmocks.NewMockTimer(t)
309- timer.EXPECT().C().Return(expiry).Maybe()
310- timer.EXPECT().Stop().Return(true).Maybe()
311- clock := portsmocks.NewMockClock(t)
312- clock.EXPECT().Now().Return(time.Time{}).Maybe()
313- clock.EXPECT().NewTimer(mock.Anything).Return(timer).Once()
314- d.clock = clock
315- transport := &parkedRouteExpiryTransport{sends: make(chan wire.Frame, 8), closed: make(chan struct{})}
316- ac.replaceTransport(transport)
317-
318- _ = armAndPrepareParkedRoute(t, d, source, ac, transport.sends)
319- expiry <- time.Time{}.Add(parkedRouteLeaseTTL)
320-
321- select {
322- case <-transport.closed:
323- case <-time.After(time.Second):
324- t.Fatal("parked transport remained open after its lease expired")
325- }
326- ac.parkedRouteMu.Lock()
327- require.Nil(t, ac.parkedRoute)
328- ac.parkedRouteMu.Unlock()
329- require.False(t, ac.parkedRouteOutput.Load())
330- require.False(t, ac.parkedRouteFullPending.Load())
331-}
332-
333-func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) {
334- d, source, ac, sends, releases := newManualTabSession(t, 1)
335- defer releaseAll(releases)
336- leaseID := protocol.ParkedRouteLeaseID{1}
337- for _, request := range []protocol.ParkedRouteRequest{
338- {RequestID: 1, LeaseID: leaseID, Action: protocol.ParkedRouteSwitch},
339- {RequestID: 2, Action: protocol.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},
340- {RequestID: 3, LeaseID: leaseID, Action: 99},
341- } {
342- capability := source.captureAttachmentCapability(ac, ac.transport())
343- effect, admitted := ac.beginAttachmentEffect(capability)
344- require.True(t, admitted)
345- d.handleParkedRouteRequest(effect, request)
346- effect.End()
347- }
348- require.Same(t, source, ac.currentAttachmentSession())
349- select {
350- case frame := <-sends:
351- t.Fatalf("malformed parked route emitted frame type %d", frame.Type)
352- default:
353- }
354-}
355-
356-func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) {
357- d, source, ac, _, releases := newManualTabSession(t, 1)
358- defer releaseAll(releases)
359- ac.replaceTransport(parkedRouteFailTransport{})
360- token := source.captureAttachmentCapability(ac, ac.transport())
361- effect, admitted := ac.beginAttachmentEffect(token)
362- require.True(t, admitted)
363-
364- result := make(chan bool, 1)
365- go func() {
366- result <- d.respondParkedRoute(effect, protocol.ParkedRouteResponse{RequestID: 1, Status: protocol.ParkedRouteReady})
367- }()
368- require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))
369- d.attachmentCleanupWg.Wait()
370-}
371-
372-func TestParkedRouteDetachClearsLeaseAndSuspension(t *testing.T) {
373- d, source, ac, sends, releases := newManualTabSession(t, 1)
374- defer releaseAll(releases)
375- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
376-
377- _ = armAndPrepareParkedRoute(t, d, source, ac, sends)
378- require.True(t, ac.parkedRouteOutput.Load())
379- require.NoError(t, d.boundedSendOutputErr(ac, []byte("suppressed side effect")))
380- select {
381- case frame := <-sends:
382- t.Fatalf("parked side effect emitted frame type %d", frame.Type)
383- default:
384- }
385-
386- d.clientGone(source, ac, ac.transport(), true)
387- ac.parkedRouteMu.Lock()
388- require.Nil(t, ac.parkedRoute)
389- ac.parkedRouteMu.Unlock()
390- require.False(t, ac.parkedRouteOutput.Load())
391-}
M internal/usecase/daemon/performance_benchmark_test.go +24 -21
@@ -48,7 +48,7 @@ func TestCountingSnapshotRepositoryDoesNotRewriteKnownObjects(t *testing.T) {
48 }48 }
49 49
50 func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) {50 func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *testing.T) {
51- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})51+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows})
52 52
53 publishBenchmarkSnapshot(t, fixture, 1)53 publishBenchmarkSnapshot(t, fixture, 1)
54 fixture.snapshots.reset()54 fixture.snapshots.reset()
@@ -64,7 +64,7 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *
64 }64 }
65 65
66 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {66 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
67- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})67+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows})
68 68
69 // This contract concerns capture and publication bytes, not queue latency.69 // This contract concerns capture and publication bytes, not queue latency.
70 // Publish synchronously so the 10k-row setup cannot race the test deadline.70 // Publish synchronously so the 10k-row setup cannot race the test deadline.
@@ -120,8 +120,8 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
120 mutate func(*performanceFixture, int)120 mutate func(*performanceFixture, int)
121 wantHistory bool121 wantHistory bool
122 }{122 }{
123- {name: "tail-only", historyRows: 9_999, mutate: mutateBenchmarkTail},123+ {name: "tail-only", historyRows: perfHistoryRows - 1, mutate: mutateBenchmarkTail},
124- {name: "new-sealed-chunk", historyRows: 10_000, mutate: mutateBenchmarkSealedChunk, wantHistory: true},124+ {name: "new-sealed-chunk", historyRows: perfHistoryRows, mutate: mutateBenchmarkSealedChunk, wantHistory: true},
125 } {125 } {
126 t.Run(tt.name, func(t *testing.T) {126 t.Run(tt.name, func(t *testing.T) {
127 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows})127 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: tt.historyRows})
@@ -142,11 +142,14 @@ func TestIncrementalSnapshotMetricScenarios(t *testing.T) {
142 }142 }
143 143
144 func TestPerformanceFixtureByteLimitedHistory(t *testing.T) {144 func TestPerformanceFixtureByteLimitedHistory(t *testing.T) {
145- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: 10_000})145+ if raceEnabled {
146+ t.Skip("byte eviction budget is calibrated without the race detector")
147+ }
148+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 400, Rows: 40}, panes: 1, historyRows: perfHistoryRows})
146 fixture.activePane.mu.Lock()149 fixture.activePane.mu.Lock()
147 rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()150 rows, bytes, budget := fixture.activePane.history.Len(), fixture.activePane.history.LogicalBytes(), fixture.activePane.history.ByteCap()
148 fixture.activePane.mu.Unlock()151 fixture.activePane.mu.Unlock()
149- require.Less(t, rows, 10_000, "byte eviction must happen before the line ceiling")152+ require.Less(t, rows, perfHistoryRows, "byte eviction must happen before the line ceiling")
150 require.Positive(t, bytes)153 require.Positive(t, bytes)
151 require.LessOrEqual(t, bytes, budget)154 require.LessOrEqual(t, bytes, budget)
152 }155 }
@@ -296,25 +299,25 @@ func TestTransactionalResizeMetrics(t *testing.T) {
296 }299 }
297 300
298 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {301 func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
299- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000})302+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: perfHistoryRows})
300 303
301 require.Len(t, fixture.sess.tabs, 4)304 require.Len(t, fixture.sess.tabs, 4)
302 for _, tb := range fixture.sess.tabs {305 for _, tb := range fixture.sess.tabs {
303 require.Len(t, tb.panes, 4)306 require.Len(t, tb.panes, 4)
304 for _, p := range tb.panes {307 for _, p := range tb.panes {
305- require.Equal(t, 10_000, p.history.Len())308+ require.Equal(t, perfHistoryRows, p.history.Len())
306 }309 }
307 }310 }
308 }311 }
309 312
310 func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {313 func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
311- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000})314+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows})
312 315
313 first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)316 first, ok := fixture.d.captureSnapshotState(fixture.sess, 1)
314 require.True(t, ok)317 require.True(t, ok)
315 require.Len(t, first.tabs, 1)318 require.Len(t, first.tabs, 1)
316 require.Len(t, first.tabs[0].panes, 2)319 require.Len(t, first.tabs[0].panes, 2)
317- require.Equal(t, 10_000, first.tabs[0].panes[0].sealed.Len())320+ require.Equal(t, perfHistoryRows, first.tabs[0].panes[0].sealed.Len())
318 require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())321 require.Positive(t, first.tabs[0].panes[0].sealed.ChunkCount())
319 322
320 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)323 second, ok := fixture.d.captureSnapshotState(fixture.sess, 2)
@@ -434,7 +437,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
434 for _, topology := range daemonHistoryTopologies {437 for _, topology := range daemonHistoryTopologies {
435 b.Run(topology.name, func(b *testing.B) {438 b.Run(topology.name, func(b *testing.B) {
436 fixture := newPerformanceFixture(b, performanceConfig{439 fixture := newPerformanceFixture(b, performanceConfig{
437- size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,440+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: perfHistoryRows,
438 })441 })
439 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {442 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
440 b.Fatal("invalid daemon history fixture")443 b.Fatal("invalid daemon history fixture")
@@ -483,7 +486,7 @@ func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run fu
483 func benchmarkDaemonSnapshotCapture(b *testing.B) {486 func benchmarkDaemonSnapshotCapture(b *testing.B) {
484 for _, topology := range daemonSnapshotTopologies {487 for _, topology := range daemonSnapshotTopologies {
485 b.Run(topology.name, func(b *testing.B) {488 b.Run(topology.name, func(b *testing.B) {
486- fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000})489+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: perfHistoryRows})
487 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {490 if !fixture.hasHistoryTopology(topology.tabs, topology.panes, 10_000) {
488 b.Fatal("invalid daemon snapshot capture fixture")491 b.Fatal("invalid daemon snapshot capture fixture")
489 }492 }
@@ -525,14 +528,14 @@ type snapshotBenchmarkScenario struct {
525 }528 }
526 529
527 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{530 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
528- {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},531+ {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows},
529- {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},532+ {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, unchanged: true},
530- {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript},533+ {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, mutate: mutateBenchmarkTranscript},
531 // Leave one row below the retention cap so the mutation changes only the534 // Leave one row below the retention cap so the mutation changes only the
532 // mutable tail and cannot evict a sealed chunk.535 // mutable tail and cannot evict a sealed chunk.
533 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},536 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
534- {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkSealedChunk},537+ {name: "new-sealed-chunk", size: domain.Size{Cols: 120, Rows: 40}, historyRows: perfHistoryRows, baseline: true, mutate: mutateBenchmarkSealedChunk},
535- {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: 10_000, byteLimited: true},538+ {name: "byte-limited-400-columns", size: domain.Size{Cols: 400, Rows: 40}, historyRows: perfHistoryRows, byteLimited: true},
536 }539 }
537 540
538 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {541 func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBenchmarkScenario) {
@@ -685,7 +688,7 @@ func benchmarkReportSnapshotRepositoryMetrics(b *testing.B, metrics countingSnap
685 }688 }
686 689
687 func benchmarkDaemonSnapshotRestore(b *testing.B) {690 func benchmarkDaemonSnapshotRestore(b *testing.B) {
688- fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})691+ fixture := newPerformanceFixture(b, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: perfHistoryRows})
689 if !fixture.hasHistoryTopology(1, 1, 10_000) {692 if !fixture.hasHistoryTopology(1, 1, 10_000) {
690 b.Fatal("invalid daemon snapshot restore fixture")693 b.Fatal("invalid daemon snapshot restore fixture")
691 }694 }
@@ -837,7 +840,7 @@ func TestLivePaintAllocationBudget(t *testing.T) {
837 {name: "1tab-4panes", panes: 4},840 {name: "1tab-4panes", panes: 4},
838 } {841 } {
839 t.Run(tt.name, func(t *testing.T) {842 t.Run(tt.name, func(t *testing.T) {
840- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})843+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: perfHistoryRows})
841 run := func() {844 run := func() {
842 fixture.paintLive()845 fixture.paintLive()
843 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)846 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
@@ -866,7 +869,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
866 {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106},869 {name: "8tabs-1pane", tabs: 8, panes: 1, max: 106},
867 } {870 } {
868 t.Run(tt.name, func(t *testing.T) {871 t.Run(tt.name, func(t *testing.T) {
869- fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})872+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: perfHistoryRows})
870 run := func() {873 run := func() {
871 fixture.d.enterCopyMode(fixture.sess, fixture.ac)874 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
872 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)875 fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
@@ -1244,7 +1247,7 @@ func (f *performanceFixture) searchMatches() int {
1244 func (f *performanceFixture) copyModeActive() bool {1247 func (f *performanceFixture) copyModeActive() bool {
1245 f.ac.overlays.copyMu.Lock()1248 f.ac.overlays.copyMu.Lock()
1246 defer f.ac.overlays.copyMu.Unlock()1249 defer f.ac.overlays.copyMu.Unlock()
1247- return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= 10_0001250+ return f.ac.overlays.copyMode != nil && f.ac.overlays.copyDocument != nil && f.ac.overlays.copyDocument.Len() >= perfHistoryRows
1248 }1251 }
1249 1252
1250 func (f *performanceFixture) resize() {1253 func (f *performanceFixture) resize() {
A internal/usecase/daemon/performance_history_race_test.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build race
2+
3+package daemon
4+
5+// The race detector makes history construction, sealing, and cloning dominate
6+// this package's runtime, while the allocation and byte budgets these fixtures
7+// feed are calibrated without it. The race build therefore keeps a smaller but
8+// still multi-chunk history.
9+const (
10+ raceEnabled = true
11+ perfHistoryRows = 600
12+)
A internal/usecase/daemon/performance_history_test.go +10 -0
@@ -0,0 +1,10 @@
1+//go:build !race
2+
3+package daemon
4+
5+// perfHistoryRows is the history depth every performance fixture builds, and
6+// the depth the compact-storage budgets are calibrated against.
7+const (
8+ raceEnabled = false
9+ perfHistoryRows = 10_000
10+)
M internal/usecase/daemon/picker.go +60 -676
@@ -3,7 +3,6 @@ package daemon
3 3
4 import (4 import (
5 "context"5 "context"
6- "errors"
7 "sort"6 "sort"
8 "time"7 "time"
9 8
@@ -11,85 +10,29 @@ import (
11 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"12 "github.com/bnema/vev/internal/protocol/catalogue"
14- "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/bnema/vev/internal/usecase/picker"14 "github.com/bnema/vev/internal/usecase/picker"
17 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
18 )16 )
19 17
20-var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}18+// The picker's floating geometry lives in the picker package: the presenting
19+// client draws the same box, and this daemon sizes the preview it captures from
20+// it, so both sides always agree.
21+var pickerModal = picker.Modal
21 22
22-// pickerSortMode orders the picker's live sessions. It lives for the daemon's23+const remotePickerPreviewDebounce = 80 * time.Millisecond
23-// lifetime only and is never persisted.
24-type pickerSortMode uint32
25 24
26-const (25+// pickerViews projects the shared daemon inventory for the picker. It keeps
27- pickerSortRecent pickerSortMode = iota26+// current/ephemeral rows, tabs, grouping, and move eligibility; lifecycle and
28- pickerSortGrouped27+// remote facts come from the common capture. Section lines are published
29-28+// whenever the source has more than one origin group, so the presenting
30- remotePickerPreviewDebounce = 80 * time.Millisecond29+// client can order each run locally without inventing group labels.
31-)30+func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]pickerSessionView, pickerSourceFilter) {
32-31+ _, grouped, current := d.pickerViewProjections(cur, ac)
33-func pickerTitle(mode pickerSortMode) string {32+ return grouped, current
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")
87 }33 }
88 34
89-// pickerViews projects the shared daemon inventory for the picker. It keeps35+func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) {
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) {
93 if cur != nil && ac != nil {36 if cur != nil && ac != nil {
94 cur.repairAttachmentView(ac)37 cur.repairAttachmentView(ac)
95 }38 }
@@ -106,17 +49,17 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
106 // renameSession mid-sort. Remote-catalog locks are released before sorting49 // renameSession mid-sort. Remote-catalog locks are released before sorting
107 // and picker-row construction.50 // and picker-row construction.
108 live := inv.live51 live := inv.live
109- var current picker.SourceFilter52+ var current pickerSourceFilter
110 for _, item := range live {53 for _, item := range live {
111 snap := item.view54 snap := item.view
112 if item.sess == cur {55 if item.sess == cur {
113 if ac != nil {56 if ac != nil {
114 view := ac.viewSnapshot()57 view := ac.viewSnapshot()
115 if view.tabID != "" {58 if view.tabID != "" {
116- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}59+ current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
117 }60 }
118 } else if len(snap.tabs) > 0 {61 } else if len(snap.tabs) > 0 {
119- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}62+ current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
120 }63 }
121 }64 }
122 }65 }
@@ -135,13 +78,6 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
135 }78 }
136 return stopped[i].name < stopped[j].name79 return stopped[i].name < stopped[j].name
137 })80 })
138- if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
139- // Stable partition: named sessions first, ephemeral after, each keeping
140- // its MRU order from the sort above.
141- sort.SliceStable(live, func(i, j int) bool {
142- return !live[i].view.ephemeral && live[j].view.ephemeral
143- })
144- }
145 81
146 catalogRows := 082 catalogRows := 0
147 for _, host := range hosts {83 for _, host := range hosts {
@@ -150,16 +86,38 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
150 catalogRows++86 catalogRows++
151 }87 }
152 }88 }
153- views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)89+ checking := monitored && !initialized
154- grouped := pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped90+ recent := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows)
91+ for _, item := range live {
92+ recent = append(recent, item.view.pickerView())
93+ }
94+ for _, host := range hosts {
95+ for _, session := range host.Sessions {
96+ key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}
97+ if key.Validate() == nil {
98+ recent = append(recent, remotePickerView(key, session, host, now))
99+ }
100+ }
101+ if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
102+ recent = append(recent, remotePickerHostView(host, now))
103+ }
104+ }
105+ if checking {
106+ recent = append(recent, remotePickerCheckingView())
107+ }
108+ for _, s := range stopped {
109+ createdAt := s.createdAt
110+ recent = append(recent, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt})
111+ }
112+
113+ views := make([]pickerSessionView, 0, len(live)+len(stopped)+catalogRows)
155 for i, item := range live {114 for i, item := range live {
156 view := item.view.pickerView()115 view := item.view.pickerView()
157- if grouped && i == 0 {116+ if i == 0 {
158 view.Section = "LOCAL"117 view.Section = "LOCAL"
159 }118 }
160 views = append(views, view)119 views = append(views, view)
161 }120 }
162- publishedRemote := false
163 for _, host := range hosts {121 for _, host := range hosts {
164 publishedForHost := 0122 publishedForHost := 0
165 for _, session := range host.Sessions {123 for _, session := range host.Sessions {
@@ -168,38 +126,29 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
168 continue126 continue
169 }127 }
170 view := remotePickerView(key, session, host, now)128 view := remotePickerView(key, session, host, now)
171- if grouped {129+ view.HideRemoteOrigin = true
172- view.HideRemoteOrigin = true130+ if publishedForHost == 0 {
173- if publishedForHost == 0 {131+ view.Section = "REMOTE " + host.Endpoint
174- view.Section = "REMOTE " + host.Endpoint
175- }
176 }132 }
177 views = append(views, view)133 views = append(views, view)
178 publishedForHost++134 publishedForHost++
179- publishedRemote = true
180 }135 }
181 if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {136 if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
182 view := remotePickerHostView(host, now)137 view := remotePickerHostView(host, now)
183- if grouped {138+ view.Section = "REMOTE " + host.Endpoint
184- view.Section = "REMOTE " + host.Endpoint
185- }
186 views = append(views, view)139 views = append(views, view)
187- publishedRemote = true
188 }140 }
189 }141 }
190 // A nil directory means remote monitoring is not installed at all:142 // A nil directory means remote monitoring is not installed at all:
191 // only an installed-but-unpublished monitor reads as "checking".143 // only an installed-but-unpublished monitor reads as "checking".
192- if monitored && !initialized {144+ if checking {
193 view := remotePickerCheckingView()145 view := remotePickerCheckingView()
194- if grouped {146+ view.Section = "REMOTE"
195- view.Section = "REMOTE"
196- }
197 views = append(views, view)147 views = append(views, view)
198- publishedRemote = true
199 }148 }
200 for i, s := range stopped {149 for i, s := range stopped {
201 createdAt := s.createdAt150 createdAt := s.createdAt
202- view := picker.SessionView{151+ view := pickerSessionView{
203 ID: domain.SessionID("stopped:" + s.name),152 ID: domain.SessionID("stopped:" + s.name),
204 Incarnation: s.incarnation,153 Incarnation: s.incarnation,
205 Name: s.name,154 Name: s.name,
@@ -207,26 +156,12 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
207 Stopped: true,156 Stopped: true,
208 ExpectedCreatedAt: &createdAt,157 ExpectedCreatedAt: &createdAt,
209 }158 }
210- if grouped && i == 0 && (len(live) == 0 || publishedRemote) {159+ if i == 0 && len(live) == 0 && catalogRows == 0 && !checking {
211 view.Section = "LOCAL"160 view.Section = "LOCAL"
212 }161 }
213 views = append(views, view)162 views = append(views, view)
214 }163 }
215- return views, current164+ return recent, 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- })
230 }165 }
231 166
232 func attentionSuffix(label string) string {167 func attentionSuffix(label string) string {
@@ -273,391 +208,9 @@ func remotePickerReasonText(reason string) string {
273 }208 }
274 }209 }
275 210
276-func pickerSearchSlashIndex(data, pending []byte) int {
277- if len(pending) != 0 {
278- return -1
279- }
280- for offset := 0; offset < len(data); {
281- if data[offset] == '/' {
282- return offset
283- }
284- if data[offset] != keys.ESC {
285- offset++
286- continue
287- }
288- if consumed, _ := routeListEscape(data[offset:]); consumed > 0 {
289- offset += consumed
290- continue
291- }
292- // An incomplete or unknown escape owns the rest of this input chunk;
293- // slash bytes inside it cannot transition the picker into search mode.
294- return -1
295- }
296- return -1
297-}
298-
299-func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
300- rt := ac.overlays
301- var previewGeneration uint64
302- return listInputState{
303- pending: &rt.pickerPending,
304- esc: &rt.pickerESC,
305- moveUp: rt.picker.Up,
306- moveDown: rt.picker.Down,
307- lock: rt.pickerMu.Lock,
308- unlock: rt.pickerMu.Unlock,
309- active: func() bool { return rt.picker != nil },
310- closeLocked: func() {
311- rt.picker = nil
312- rt.pickerIntent = pickerNavigate
313- rt.pickerSource = moveSourceLocator{}
314- previewGeneration = rt.pickerPreviewGeneration
315- },
316- afterClose: func() {
317- d.clearPreviewGeneration(ac, previewGeneration)
318- if sess := ac.currentAttachmentSession(); sess != nil {
319- d.invalidateRender(sess, ac, true, "picker.go")
320- }
321- },
322- }
323-}
324-
325-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
326- sess := ac.currentAttachmentSession()
327- if sess == nil {
328- return
329- }
330- rt := ac.overlays
331- rt.pickerMu.Lock()
332- if rt.picker == nil {
333- rt.pickerPending = nil
334- rt.pickerESC.stop()
335- rt.pickerMu.Unlock()
336- return
337- }
338- normalAction := func(b byte) listInputResult {
339- switch b {
340- case 'x', 's':
341- return listInputResult{action: b, stop: true}
342- case '\r', '\n':
343- return listInputResult{action: b, exit: true}
344- default:
345- return listInputResult{}
346- }
347- }
348- var result listInputResult
349- if rt.picker.SearchActive() {
350- result = d.handlePickerSearchInputLocked(ac, data)
351- } else if slash := pickerSearchSlashIndex(data, rt.pickerPending); slash >= 0 {
352- result = handleListInputLocked(d.clock, data[:slash], d.pickerListInputState(ac), normalAction)
353- if result.action == 0 && !result.exit && len(rt.pickerPending) == 0 {
354- rt.picker.EnterSearch()
355- result.changed = true
356- if slash+1 < len(data) {
357- searchResult := d.handlePickerSearchInputLocked(ac, data[slash+1:])
358- searchResult.changed = searchResult.changed || result.changed
359- result = searchResult
360- }
361- }
362- } else {
363- result = handleListInputLocked(d.clock, data, d.pickerListInputState(ac), normalAction)
364- }
365- var target picker.Target
366- var cursor picker.Target
367- var ok bool
368- var cursorOK bool
369- var rejectedBySearch bool
370- observedPicker := rt.picker
371- observedGeneration := rt.pickerGeneration
372- var intent pickerIntent
373- var source moveSourceLocator
374- // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
375- prevIdx := -1
376- if result.action == 'x' || result.action == '\r' || result.action == '\n' {
377- target, ok = rt.picker.Selected()
378- cursor, cursorOK = rt.picker.Cursor()
379- rejectedBySearch = rt.picker.SelectionRejectedBySearch()
380- prevIdx = rt.picker.SelectedIndex()
381- }
382- intent, source = rt.pickerIntent, rt.pickerSource
383- rt.pickerMu.Unlock()
384-
385- if !d.pickerInstanceCurrent(ac, observedPicker, observedGeneration) {
386- return
387- }
388- if result.action == 's' {
389- d.togglePickerSort()
390- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
391- d.invalidateRender(sess, ac, true, "picker.go")
392- return
393- }
394- if (result.action == '\r' || result.action == '\n') && !ok && rejectedBySearch {
395- d.invalidateRender(sess, ac, true, "picker search no match")
396- return
397- }
398- if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
399- d.notifyRemotePickerUnavailable(sess, cursor)
400- d.invalidateRender(sess, ac, true, "picker.go")
401- return
402- }
403- if result.action == 'x' {
404- var effect *attachmentEffect
405- if len(effects) != 0 {
406- effect = effects[0]
407- }
408- if ok {
409- var err error
410- if effect != nil {
411- effect.bindActionEnd(d, "picker-delete")
412- err = d.killPickerTargetForAttachment(target, effect)
413- } else {
414- err = d.killPickerTarget(target)
415- }
416- if err != nil {
417- d.reportAttachmentError(sess, err)
418- }
419- }
420- if effect != nil && !effect.current() {
421- fresh, admitted := ac.beginAttachmentEffect(effect.capability())
422- if !admitted {
423- return
424- }
425- defer fresh.End()
426- }
427- d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
428- d.invalidateRender(sess, ac, true, "picker.go")
429- return
430- }
431- if result.changed {
432- d.registerPreviewForSelection(ac)
433- }
434- committing := (result.action == '\r' || result.action == '\n') && ok
435- backNavigationSent := false
436- if result.exit && !committing {
437- back := ac.startupOverlay == protocol.StartupOverlaySessionPicker && ac.navigationCapabilities&protocol.NavigationCapabilityBack != 0
438- if back {
439- if len(effects) == 0 || effects[0] == nil {
440- d.invalidateRender(sess, ac, true, "picker.go")
441- return
442- }
443- effect := effects[0]
444- if err := d.sendNavigationActionForAttachment(effect, protocol.NavigationBack); err != nil {
445- d.reportAttachmentError(sess, err)
446- d.invalidateRender(sess, ac, true, "picker.go")
447- return
448- }
449- backNavigationSent = true
450- }
451- d.closePickerIfCurrent(ac, observedPicker, observedGeneration)
452- }
453- if committing {
454- if intent == pickerMovePane || intent == pickerMoveTab {
455- if err := d.movePickerSourceError(source); err != nil {
456- d.reportAttachmentError(sess, err)
457- d.invalidateRender(sess, ac, true, "picker.go")
458- return
459- }
460- if len(effects) != 0 && effects[0] != nil {
461- effects[0].End()
462- }
463- if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
464- return
465- }
466- err := d.commitMovePickerSelection(intent, source, target)
467- if err != nil {
468- d.reportAttachmentError(sess, movePickerUserError(err))
469- d.invalidateRender(sess, ac, true, "picker.go")
470- return
471- }
472- d.invalidateRender(sess, ac, true, "picker.go")
473- return
474- }
475- var err error
476- if len(effects) != 0 && effects[0] != nil {
477- err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true, pickerModel: observedPicker, pickerGeneration: observedGeneration}, "picker-select")
478- } else {
479- if !d.closePickerIfCurrent(ac, observedPicker, observedGeneration) {
480- return
481- }
482- err = d.switchToTarget(sess, ac, target)
483- }
484- if errors.Is(err, errAttachmentTransition) {
485- return
486- }
487- if err != nil {
488- d.reportAttachmentError(sess, err)
489- return
490- }
491- return
492- }
493- if (result.exit || result.changed) && !backNavigationSent {
494- d.invalidateRender(sess, ac, true, "picker.go")
495- }
496-}
497-
498-func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
499- if ac == nil || ac.overlays == nil {
500- return
501- }
502- ac.overlays.pickerMu.Lock()
503- var target picker.Target
504- var ok bool
505- intent := ac.overlays.pickerIntent
506- if ac.overlays.picker != nil {
507- target, ok = ac.overlays.picker.Selected()
508- }
509- previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
510- ac.overlays.pickerPreviewGeneration++
511- generation := ac.overlays.pickerPreviewGeneration
512- if ac.overlays.pickerRemotePreviewCancel != nil {
513- ac.overlays.pickerRemotePreviewCancel()
514- ac.overlays.pickerRemotePreviewCancel = nil
515- }
516- ac.overlays.pickerPreviewSession = nil
517- ac.overlays.pickerRemotePreview = picker.Preview{}
518- ac.overlays.pickerPreview = nil
519- ac.overlays.pickerMu.Unlock()
520- d.teardownPreviewSubscription(ac, previous, previousGeneration)
521- if !ok {
522- return
523- }
524- if target.RemoteTarget != nil {
525- d.startRemotePickerPreview(ac, target, generation)
526- return
527- }
528- targetSess, next := d.previewTarget(target, intent)
529- if next == nil || targetSess == nil {
530- return
531- }
532- ac.overlays.pickerMu.Lock()
533- // Selection may have changed while the target was resolved.
534- selected, stillSelected := ac.overlays.picker.Selected()
535- valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerRouteTargetsEqual(selected, target)
536- if valid {
537- ac.overlays.pickerPreview = next
538- ac.overlays.pickerPreviewSession = targetSess
539- }
540- ac.overlays.pickerMu.Unlock()
541- if !valid {
542- return
543- }
544- // A same-session preview is already invalidated by its own coordinator.
545- // Cross-session previews subscribe to the target's producer wake instead
546- // of starting a direct paint/timer path.
547- if targetSess == ac.currentAttachmentSession() {
548- return
549- }
550- rc := d.attachCoordinator(targetSess, nil, nil, false)
551- rc.subscribePreviewFor(ac, generation, func(renderWake) {
552- if pickerPreviewCurrent(ac, targetSess, next, generation) {
553- if owner := ac.currentAttachmentSession(); owner != nil {
554- d.invalidateRender(owner, ac, false, "picker preview")
555- }
556- }
557- })
558- // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it
559- // returns and remove only this generation if selection changed meanwhile.
560- d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
561-}
562-
563-func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) {
564- if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) {
565- return
566- }
567- width, height := remotePickerPreviewSize(ac.sizeSnapshot())
568- if width == 0 || height == 0 {
569- return
570- }
571- ctx := d.serveCtx
572- if ctx == nil {
573- ctx = context.Background()
574- }
575- previewCtx, cancel := context.WithCancel(ctx)
576- ac.overlays.pickerMu.Lock()
577- if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation {
578- ac.overlays.pickerMu.Unlock()
579- cancel()
580- return
581- }
582- ac.overlays.pickerRemotePreviewCancel = cancel
583- remoteTarget := *target.RemoteTarget
584- message := "loading remote preview…"
585- if remoteTarget.Stopped {
586- message = "stopped session — preview unavailable"
587- }
588- ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
589- if remoteTarget.Stopped {
590- ac.overlays.pickerRemotePreviewCancel = nil
591- ac.overlays.pickerMu.Unlock()
592- cancel()
593- if sess := ac.currentAttachmentSession(); sess != nil {
594- d.invalidateRender(sess, ac, false, "remote picker preview")
595- }
596- return
597- }
598- ac.overlays.pickerMu.Unlock()
599- go func() {
600- defer cancel()
601- previewClock := d.clock
602- if previewClock == nil {
603- previewClock = systemClock{}
604- }
605- debounce := previewClock.NewTimer(remotePickerPreviewDebounce)
606- defer debounce.Stop()
607- select {
608- case <-debounce.C():
609- case <-previewCtx.Done():
610- return
611- }
612- preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
613- if err != nil {
614- ac.overlays.pickerMu.Lock()
615- matching := false
616- if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
617- selected, stillSelected := ac.overlays.picker.Selected()
618- matching = stillSelected && pickerRouteTargetsEqual(selected, target)
619- if matching {
620- ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
621- }
622- }
623- ac.overlays.pickerMu.Unlock()
624- if matching {
625- if sess := ac.currentAttachmentSession(); sess != nil {
626- d.invalidateRender(sess, ac, false, "remote picker preview")
627- }
628- }
629- return
630- }
631- if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil {
632- preview = refreshed
633- } else if previewCtx.Err() != nil {
634- return
635- }
636- rows := preview.FrameRows()
637- candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
638- ac.overlays.pickerMu.Lock()
639- if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil {
640- ac.overlays.pickerMu.Unlock()
641- return
642- }
643- selected, stillSelected := ac.overlays.picker.Selected()
644- if !stillSelected || !pickerRouteTargetsEqual(selected, target) {
645- ac.overlays.pickerMu.Unlock()
646- return
647- }
648- ac.overlays.pickerRemotePreview = candidate
649- ac.overlays.pickerRemotePreviewCancel = nil
650- ac.overlays.pickerMu.Unlock()
651- if sess := ac.currentAttachmentSession(); sess != nil {
652- d.invalidateRender(sess, ac, false, "remote picker preview")
653- }
654- }()
655-}
656-
657 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {211 func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
658- presentation := pickerModal.Resolve(size)212+ preview := picker.PreviewRect(size)
659- geometry := picker.ChooseGeometry(rectSize(presentation.Inner))213+ width, height := preview.Width, preview.Height
660- width, height := geometry.Preview.Width, geometry.Preview.Height
661 if width <= 0 || height <= 0 {214 if width <= 0 || height <= 0 {
662 return 0, 0215 return 0, 0
663 }216 }
@@ -682,137 +235,6 @@ func staticRemotePickerPreview(width, height uint16, message string) picker.Prev
682 return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}235 return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
683 }236 }
684 237
685-func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
686- ac.overlays.pickerMu.Lock()
687- defer ac.overlays.pickerMu.Unlock()
688- return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next
689-}
690-
691-// revalidatePreviewSubscription removes a subscription that lost the picker
692-// selection race after it was installed.
693-func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) {
694- if !pickerPreviewCurrent(ac, targetSess, next, generation) {
695- rc.teardownPreviewFor(ac, generation)
696- }
697-}
698-
699-func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) {
700- if previous != nil {
701- if rc := previous.renderCoordinator(); rc != nil {
702- rc.teardownPreviewFor(ac, generation)
703- }
704- }
705-}
706-
707-func (d *Daemon) unregisterPreview(ac *attachedClient) {
708- if ac == nil || ac.overlays == nil {
709- return
710- }
711- ac.overlays.pickerMu.Lock()
712- generation := ac.overlays.pickerPreviewGeneration
713- ac.overlays.pickerMu.Unlock()
714- d.clearPreviewGeneration(ac, generation)
715-}
716-
717-// clearPreviewGeneration removes the preview only if it is still the observed
718-// selection. Teardown paths may race a new picker selection.
719-func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool {
720- ac.overlays.pickerMu.Lock()
721- if ac.overlays.pickerPreviewGeneration != generation {
722- ac.overlays.pickerMu.Unlock()
723- return false
724- }
725- previous := ac.overlays.pickerPreviewSession
726- ac.overlays.pickerPreviewGeneration++
727- if ac.overlays.pickerRemotePreviewCancel != nil {
728- ac.overlays.pickerRemotePreviewCancel()
729- ac.overlays.pickerRemotePreviewCancel = nil
730- }
731- ac.overlays.pickerPreviewSession = nil
732- ac.overlays.pickerRemotePreview = picker.Preview{}
733- ac.overlays.pickerPreview = nil
734- ac.overlays.pickerMu.Unlock()
735- d.teardownPreviewSubscription(ac, previous, generation)
736- return true
737-}
738-
739-// clearDestroyedTabPreview clears coordinator-owned picker state for clients
740-// that were previewing a removed tab. Tabs do not route render ownership.
741-func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
742- if d == nil || d.sessions == nil {
743- return
744- }
745- d.mu.Lock()
746- var clients []*attachedClient
747- for _, sess := range d.sessions {
748- if sess == nil {
749- continue
750- }
751- sess.mu.Lock()
752- clients = append(clients, sess.snapshotAttachmentsLocked()...)
753- sess.mu.Unlock()
754- }
755- d.mu.Unlock()
756- for _, ac := range clients {
757- if ac == nil || ac.overlays == nil {
758- continue
759- }
760- ac.overlays.pickerMu.Lock()
761- generation := ac.overlays.pickerPreviewGeneration
762- observed := ac.overlays.pickerPreview == tb
763- ac.overlays.pickerMu.Unlock()
764- if observed && d.clearPreviewGeneration(ac, generation) {
765- if sess := ac.currentAttachmentSession(); sess != nil {
766- d.invalidateRender(sess, ac, true, "picker.go")
767- }
768- }
769- }
770-}
771-
772-func (d *Daemon) closePicker(ac *attachedClient) {
773- d.closePickerIfCurrent(ac, nil, 0)
774-}
775-
776-func (d *Daemon) pickerInstanceCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
777- if ac == nil || ac.overlays == nil {
778- return false
779- }
780- ac.overlays.pickerMu.Lock()
781- defer ac.overlays.pickerMu.Unlock()
782- return ac.overlays.picker == model && ac.overlays.pickerGeneration == generation
783-}
784-
785-// closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
786-// generation guards terminal parked cleanup even when a refresh replaced the
787-// model within that generation.
788-func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
789- return d.closePickerIfCurrentRefresh(ac, model, generation, 0)
790-}
791-
792-func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.Model, generation, refreshSequence uint64) bool {
793- if ac == nil || ac.overlays == nil {
794- return false
795- }
796- ac.overlays.pickerMu.Lock()
797- refreshChanged := refreshSequence != 0 && ac.overlays.pickerRefreshSequence != refreshSequence
798- modelChanged := model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation)
799- generationChanged := model == nil && generation != 0 && ac.overlays.pickerGeneration != generation
800- if refreshChanged || modelChanged || generationChanged {
801- ac.overlays.pickerMu.Unlock()
802- return false
803- }
804- ac.overlays.picker = nil
805- ac.overlays.pickerTitle = ""
806- ac.overlays.pickerIntent = pickerNavigate
807- ac.overlays.pickerSource = moveSourceLocator{}
808- ac.overlays.pickerPending = nil
809- ac.overlays.pickerESC.stop()
810- previewGeneration := ac.overlays.pickerPreviewGeneration
811- ac.overlays.pickerMu.Unlock()
812- d.clearPreviewGeneration(ac, previewGeneration)
813- return true
814-}
815-
816 // pickerRouteTargetsEqual compares the exact route identity of two picker238 // pickerRouteTargetsEqual compares the exact route identity of two picker
817 // targets, ignoring presentation fields such as UnavailableReason that239 // targets, ignoring presentation fields such as UnavailableReason that
818 // render around the route on every observation.240 // render around the route on every observation.
@@ -853,19 +275,8 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
853 // one active-session handoff. Its zero value keeps daemon-owned transitions on275 // one active-session handoff. Its zero value keeps daemon-owned transitions on
854 // their established path.276 // their established path.
855 type sessionHandoffGuard struct {277 type sessionHandoffGuard struct {
856- expectedSource *tab278+ expectedSource *tab
857- closePicker bool279+ allowSamePeer 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
869 }280 }
870 281
871 // switchActiveTargetForAttachment hands a frame-bound navigation request to the282 // switchActiveTargetForAttachment hands a frame-bound navigation request to the
@@ -932,13 +343,6 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
932 }343 }
933 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)344 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
934 }345 }
935- if guard.closePicker {
936- fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
937- if admitted {
938- d.closePickerForGuard(effect.ac, guard)
939- fresh.End()
940- }
941- }
942 d.touchMRU(targetSess)346 d.touchMRU(targetSess)
943 d.deferAttachmentTransitionCleanups(transition)347 d.deferAttachmentTransitionCleanups(transition)
944 d.firstPaintForTransition(transition.published)348 d.firstPaintForTransition(transition.published)
@@ -998,17 +402,14 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
998 if !matches || !effect.current() {402 if !matches || !effect.current() {
999 return errAttachmentTransition403 return errAttachmentTransition
1000 }404 }
1001- homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker405+ if targetSess != nil && target.TabID != "" {
1002- if !homePicker && targetSess != nil && target.TabID != "" {406+ // Ordinary local tab navigation keeps the attachment.
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.
1006 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)407 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
1007 }408 }
1008 409
1009 // Ordinary live session rows can use the client-confirmed same-peer410 // Ordinary live session rows can use the client-confirmed same-peer
1010- // path. Temporary home pickers and stopped targets require a handoff.411+ // path. Stopped targets require a handoff.
1011- samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0412+ samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
1012 if exactTarget == nil {413 if exactTarget == nil {
1013 return errAttachmentTransition414 return errAttachmentTransition
1014 }415 }
@@ -1041,9 +442,6 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta
1041 // handoff instead of losing their target-specific transition semantics.442 // handoff instead of losing their target-specific transition semantics.
1042 // Keep the source link open until the client receives the ordered cleanup443 // Keep the source link open until the client receives the ordered cleanup
1043 // and handoff frames, then let the client's close drive ordinary parking.444 // and handoff frames, then let the client's close drive ordinary parking.
1044- if guard.closePicker {
1045- d.closePickerForGuard(effect.ac, guard)
1046- }
1047 effect.bindActionEnd(d, "detach")445 effect.bindActionEnd(d, "detach")
1048 effect.End()446 effect.End()
1049 return nil447 return nil
@@ -1081,9 +479,6 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
1081 if err := effect.sendControl(handoff); err != nil {479 if err := effect.sendControl(handoff); err != nil {
1082 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)480 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
1083 }481 }
1084- if guard.closePicker {
1085- d.closePickerForGuard(effect.ac, guard)
1086- }
1087 d.clientGoneForAttachment(effect, false)482 d.clientGoneForAttachment(effect, false)
1088 return nil483 return nil
1089 }484 }
@@ -1178,17 +573,6 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1178 d.invalidateRender(from, ac, true, "picker.go")573 d.invalidateRender(from, ac, true, "picker.go")
1179 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)574 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1180 }575 }
1181- if guard.closePicker {
1182- if sourceEffect != nil {
1183- fresh, admitted := ac.beginAttachmentEffect(transition.published)
1184- if admitted {
1185- d.closePickerForGuard(ac, guard)
1186- fresh.End()
1187- }
1188- } else {
1189- d.closePickerForGuard(ac, guard)
1190- }
1191- }
1192 d.deferAttachmentTransitionCleanups(transition)576 d.deferAttachmentTransitionCleanups(transition)
1193 if targetSess == from {577 if targetSess == from {
1194 if sourceEffect != nil {578 if sourceEffect != nil {
A internal/usecase/daemon/picker_control.go +96 -0
@@ -0,0 +1,96 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ "github.com/bnema/vev/internal/protocol"
6+ "github.com/bnema/vev/internal/usecase/picker"
7+)
8+
9+const controlPickerInteractionID uint64 = 1
10+
11+// handlePickerControl serves the stable local authority without creating an
12+// attachment. The snapshot and resolve paths rebuild from the same daemon
13+// projection, so eligibility and opaque-key ownership never move client-side.
14+func (d *Daemon) handlePickerControl(tr ports.ServerConnection, request protocol.PickerControlRequest) {
15+ defer tr.Close()
16+ response := d.answerPickerControl(request)
17+ _ = d.boundedControlSend(tr, response)
18+}
19+
20+func (d *Daemon) answerPickerControl(request protocol.PickerControlRequest) protocol.PickerControlResponse {
21+ response := protocol.PickerControlResponse{RequestID: request.RequestID, Operation: request.Operation, Status: protocol.PickerSourceUnavailable}
22+ if protocol.ValidatePickerControlRequest(request) != nil || request.Version != protocol.Version {
23+ return response
24+ }
25+ recentViews, groupedViews, _ := d.pickerViewProjections(nil, nil)
26+ recent := pickerLineSetFor(recentViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
27+ grouped := pickerLineSetFor(groupedViews, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
28+ // Resolution revalidates the opaque key against the current catalogue.
29+ // A constant revision avoids rejecting an otherwise current target merely
30+ // because unrelated presentation text changed after the client snapshot.
31+ revision := uint64(1)
32+ if request.Operation == protocol.PickerControlSnapshot {
33+ snapshot := protocol.PickerSnapshot{InteractionID: controlPickerInteractionID, SourceID: protocol.PickerHomeSourceID, SourceRevision: revision, Status: protocol.PickerSourceOK, Lines: recent.lines, Cursor: recent.cursor, Recent: protocol.PickerProjection{Lines: recent.lines, Cursor: recent.cursor}, Grouped: protocol.PickerProjection{Lines: grouped.lines, Cursor: grouped.cursor}}
34+ response.Status, response.Snapshot = protocol.PickerSourceOK, &snapshot
35+ return response
36+ }
37+ if request.Operation == protocol.PickerControlObserve {
38+ response.Status = protocol.PickerSourceOK
39+ response.Observations = d.observePickerRoutes(request.Targets)
40+ return response
41+ }
42+ if request.SourceID != protocol.PickerHomeSourceID {
43+ return response
44+ }
45+ target, ok := recent.keys[request.Key]
46+ if !ok || !d.pickerTargetCurrent(target) {
47+ return response
48+ }
49+ resolved, ok := d.pickerControlAttachTarget(target)
50+ if !ok {
51+ return response
52+ }
53+ response.Status, response.Resolved = protocol.PickerSourceOK, &resolved
54+ return response
55+}
56+
57+func (d *Daemon) observePickerRoutes(targets []protocol.ExactSessionTarget) []protocol.PickerRouteObservation {
58+ observations := make([]protocol.PickerRouteObservation, len(targets))
59+ d.mu.Lock()
60+ sessions := d.sessionsSnapshotLocked()
61+ d.mu.Unlock()
62+ for i, target := range targets {
63+ observation := protocol.PickerRouteObservation{Target: target, Presence: protocol.PickerRouteAbsent}
64+ for _, sess := range sessions {
65+ view := sess.snapshotView(viewOptions{})
66+ if view.incarnation != target.LifecycleID || view.name != target.SessionName {
67+ continue
68+ }
69+ observation.Presence = protocol.PickerRoutePresent
70+ observation.Attention = view.hasAttention
71+ break
72+ }
73+ observations[i] = observation
74+ }
75+ return observations
76+}
77+
78+func (d *Daemon) pickerControlAttachTarget(target picker.Target) (protocol.AttachTarget, bool) {
79+ if target.RemoteTarget != nil && target.RemoteKey != nil {
80+ remote := *target.RemoteTarget
81+ if !d.remoteCatalogTargetReady(remote) {
82+ return protocol.AttachTarget{}, false
83+ }
84+ return protocol.AttachTarget{Endpoint: remote.Endpoint, Session: remote.SessionName, Intent: protocol.IntentAttach, RemoteTarget: &remote, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
85+ }
86+ d.mu.Lock()
87+ defer d.mu.Unlock()
88+ sess := d.sessions[target.Session]
89+ if sess == nil {
90+ return protocol.AttachTarget{}, false
91+ }
92+ sess.mu.Lock()
93+ exact := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
94+ sess.mu.Unlock()
95+ return protocol.AttachTarget{Session: exact.SessionName, Intent: protocol.IntentAttach, ExactTarget: &exact, PreferredTabID: target.TabID, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, true
96+}
A internal/usecase/daemon/picker_control_test.go +31 -0
@@ -0,0 +1,31 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestPickerControlObserveReportsExactAttention(t *testing.T) {
12+ d, sess, _, _ := newManualSessionWithPTYs(t, nil)
13+ sess.mu.Lock()
14+ sess.incarnation = domain.SessionLifecycleID{9}
15+ sess.tabs[0].attention = true
16+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
17+ sess.mu.Unlock()
18+ missing := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: sess.name}
19+
20+ response := d.answerPickerControl(protocol.PickerControlRequest{
21+ Version: protocol.Version, RequestID: 1, Operation: protocol.PickerControlObserve,
22+ Targets: []protocol.ExactSessionTarget{target, missing},
23+ })
24+
25+ require.Equal(t, protocol.PickerSourceOK, response.Status)
26+ require.Equal(t, []protocol.PickerRouteObservation{
27+ {Target: target, Presence: protocol.PickerRoutePresent, Attention: true},
28+ {Target: missing, Presence: protocol.PickerRouteAbsent},
29+ }, response.Observations)
30+ require.NoError(t, protocol.ValidatePickerControlResponse(response))
31+}
A internal/usecase/daemon/picker_interaction.go +557 -0
@@ -0,0 +1,557 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "slices"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/usecase/picker"
10+)
11+
12+// This file owns the serving-daemon side of the picker interaction. The
13+// daemon is the data and action authority: it publishes one structured line
14+// set per source, resolves opaque keys, and performs the navigation, move, or
15+// kill the client asked for. It never owns a presentation model.
16+
17+// servingPickerSourceID names the source the serving daemon itself owns.
18+const servingPickerSourceID = protocol.PickerServingSourceID
19+
20+// pickerDuplicateRows reports the first repeated row key with a readable
21+// identity for both rows. A refused snapshot otherwise says only that a key
22+// repeated, which is not enough to find the two rows that collided.
23+func pickerDuplicateRows(lines []protocol.PickerLine) (first, second string, duplicated bool) {
24+ seen := make(map[string]string, len(lines))
25+ for _, line := range lines {
26+ if line.Key == "" {
27+ continue
28+ }
29+ identity := fmt.Sprintf("%s %q (%s)", line.Label, line.Detail, line.StatusDetail)
30+ if previous, ok := seen[line.Key]; ok {
31+ return previous, identity, true
32+ }
33+ seen[line.Key] = identity
34+ }
35+ return "", "", false
36+}
37+
38+// pickerClientKey derives a stable opaque key for an unchanged navigation
39+// target (lifecycle, name). Presentation-only updates keep the key; a changed
40+// or removed target retires it and a reappearing target earns a new key
41+// through its new lifecycle. Same role as navigationInventoryEntryKey.
42+func pickerClientKey(lifecycle domain.IncarnationID, name string) string {
43+ return navigationInventoryEntryKey(lifecycle, name)
44+}
45+
46+// retirePicker closes the picker interaction of an attachment that is going
47+// away. It never sends: the transport is already failing or parked, and the
48+// client's own lease retires on its generation change.
49+func (d *Daemon) retirePicker(ac *attachedClient) {
50+ if ac == nil || ac.overlays == nil {
51+ return
52+ }
53+ ac.overlays.pickerMu.Lock()
54+ open := ac.overlays.pickerOpen
55+ interaction := ac.overlays.pickerInteraction
56+ ac.overlays.pickerMu.Unlock()
57+ if !open {
58+ return
59+ }
60+ d.closePickerForAttachment(ac, nil, interaction)
61+}
62+
63+// pickerState reports the open interaction under the picker mutex.
64+func (ac *attachedClient) pickerState() (open bool, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) {
65+ if ac == nil || ac.overlays == nil {
66+ return false, 0, 0, moveSourceLocator{}, 0
67+ }
68+ ac.overlays.pickerMu.Lock()
69+ defer ac.overlays.pickerMu.Unlock()
70+ return ac.overlays.pickerOpen, ac.overlays.pickerInteraction, ac.overlays.pickerIntent, ac.overlays.pickerMoveSource, ac.overlays.pickerRequestID
71+}
72+
73+// openPickerForAttachment admits one picker interaction on a guarded serving
74+// connection, sends the offer that names the intent and the acquisition
75+// barrier, and publishes the first serving snapshot. A failed publication
76+// fails closed: the interaction is retired again so no namespace survives
77+// without its client-side acquisition.
78+func (d *Daemon) openPickerForAttachment(ac *attachedClient, effect *attachmentEffect, intent protocol.PickerIntent, source moveSourceLocator, requestID uint64) error {
79+ if ac == nil || ac.overlays == nil || effect == nil || !effect.current() {
80+ return errAttachmentTransition
81+ }
82+ sess := effect.sess
83+ if intent != protocol.PickerIntentNavigation {
84+ if sess == nil || !sess.capabilities().yieldsMoves() {
85+ return errSessionCannotYieldMoves
86+ }
87+ if source.Attachment != nil && source.AttachmentCapability.ac == nil {
88+ source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport())
89+ }
90+ }
91+ ac.overlays.pickerMu.Lock()
92+ previousSession, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
93+ interaction := ac.overlays.pickerInteraction + 1
94+ if interaction == 0 {
95+ interaction = 1
96+ }
97+ ac.overlays.pickerOpen = true
98+ ac.overlays.pickerInteraction = interaction
99+ ac.overlays.pickerIntent = intent
100+ ac.overlays.pickerMoveSource = source
101+ ac.overlays.pickerRequestID = requestID
102+ ac.overlays.pickerRevisions = make(map[string]uint64)
103+ ac.overlays.pickerKeys = nil
104+ ac.overlays.pickerSourcePublished = false
105+ ac.overlays.pickerRecent = protocol.PickerProjection{}
106+ ac.overlays.pickerGrouped = protocol.PickerProjection{}
107+ ac.overlays.pickerPreviewGeneration = 0
108+ ac.overlays.pickerPreviewKey = ""
109+ ac.overlays.pickerPreviewSession = nil
110+ ac.overlays.pickerMu.Unlock()
111+ // A previous interaction's row must stop observing before this one owns the
112+ // namespace, exactly as retiring the interaction would.
113+ d.teardownPickerPreviewSubscription(ac, previousSession, previousGeneration)
114+
115+ barrierEpoch, barrierState, sizeEpoch := pickerClientBarrier(ac)
116+ offer := protocol.PickerOffer{
117+ InteractionID: interaction, RequestID: requestID, Intent: intent,
118+ MoveSourceKey: pickerMoveSourceKey(source), Title: picker.SortRecent.Title(),
119+ BarrierEpoch: barrierEpoch, BarrierState: barrierState, SizeEpoch: sizeEpoch,
120+ }
121+ if err := protocol.ValidatePickerOffer(offer); err != nil {
122+ d.log.Error("picker offer rejected", "err", err, "interaction", interaction, "intent", intent)
123+ d.closePickerForAttachment(ac, effect, interaction)
124+ return err
125+ }
126+ if err := effect.sendControl(offer); err != nil {
127+ d.closePickerForAttachment(ac, effect, interaction)
128+ return err
129+ }
130+ if err := d.publishPickerSourceForAttachment(ac, effect, interaction); err != nil {
131+ d.closePickerForAttachment(ac, effect, interaction)
132+ return err
133+ }
134+ if sess := ac.currentAttachmentSession(); sess != nil {
135+ d.invalidateRender(sess, ac, true, "picker.go:open")
136+ }
137+ return nil
138+}
139+
140+// pickerMoveSourceKey names the captured move source in the offer. It is
141+// opaque and never a route: only the daemon resolves it, and only for the
142+// interaction that captured it.
143+func pickerMoveSourceKey(source moveSourceLocator) string {
144+ if source.Session.ID == "" || source.TabID == "" {
145+ return ""
146+ }
147+ return pickerClientKey(source.Session.Incarnation, string(source.Session.ID)+"#"+string(source.TabID))
148+}
149+
150+// publishPickerSourceForAttachment builds the serving projection pair and
151+// publishes it only when the authoritative presentation changed. Recent is the
152+// flat recency order the default client sort consumes; Grouped is the sectioned
153+// order the grouped sort consumes. They always carry the same destination keys
154+// and actions, but their lines differ, so each client sort runs on the input
155+// its mode expects instead of re-deriving one projection from the other. A
156+// refresh whose projections equal the last published pair is not
157+// authority-relevant — an attention pulse whose rows already reached the client
158+// is the common case — and must neither bump the revision nor send a snapshot,
159+// or a revision the client never observed would stale its next selection. The
160+// revision and recorded projections commit only after the snapshot reached the
161+// client, so a rejected validation or failed send leaves the client's last
162+// observed authority intact and an identical retry republishes instead of
163+// being suppressed.
164+// Callers must not hold pickerMu.
165+func (d *Daemon) publishPickerSourceForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) error {
166+ if ac == nil || ac.overlays == nil || effect == nil {
167+ return errAttachmentTransition
168+ }
169+ open, currentInteraction, intent, source, _ := ac.pickerState()
170+ if !open || currentInteraction != interaction {
171+ return nil
172+ }
173+ sess := effect.sess
174+ if sess == nil {
175+ sess = ac.currentAttachmentSession()
176+ }
177+ if sess == nil {
178+ return errAttachmentTransition
179+ }
180+ recentViews, groupedViews, current := d.pickerViewProjections(sess, ac)
181+ filter := pickerSourceFilter{
182+ Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
183+ }
184+ groupedSet := pickerLineSetFor(groupedViews, intent, filter, current)
185+ if intent != protocol.PickerIntentNavigation && !pickerLineSetHasMove(groupedSet) {
186+ return errNoMoveDestination
187+ }
188+ recent := setProjection(pickerLineSetFor(recentViews, intent, filter, current))
189+ grouped := setProjection(groupedSet)
190+ ac.overlays.pickerPublishMu.Lock()
191+ defer ac.overlays.pickerPublishMu.Unlock()
192+ ac.overlays.pickerMu.Lock()
193+ if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
194+ ac.overlays.pickerMu.Unlock()
195+ return nil
196+ }
197+ if ac.overlays.pickerSourcePublished &&
198+ pickerProjectionEqual(ac.overlays.pickerRecent, recent) &&
199+ pickerProjectionEqual(ac.overlays.pickerGrouped, grouped) {
200+ ac.overlays.pickerMu.Unlock()
201+ return nil
202+ }
203+ // Reserve the next revision without committing it: a revision the client
204+ // never observed must not become the recorded authority. pickerPublishMu
205+ // keeps this reservation exclusive, so the send below cannot be reordered
206+ // behind another publisher's.
207+ revision := ac.overlays.pickerRevisions[servingPickerSourceID] + 1
208+ ac.overlays.pickerMu.Unlock()
209+ snapshot := protocol.PickerSnapshot{
210+ InteractionID: interaction, SourceID: servingPickerSourceID, SourceRevision: revision,
211+ Status: protocol.PickerSourceOK, Lines: groupedSet.lines, Cursor: groupedSet.cursor,
212+ Recent: recent, Grouped: grouped,
213+ }
214+ if err := protocol.ValidatePickerSnapshot(snapshot); err != nil {
215+ attrs := []any{"err", err, "interaction", interaction, "lines", len(snapshot.Lines), "revision", revision}
216+ if first, second, dup := pickerDuplicateRows(snapshot.Lines); dup {
217+ attrs = append(attrs, "first_row", first, "second_row", second)
218+ }
219+ d.log.Error("picker snapshot rejected", attrs...)
220+ return err
221+ }
222+ if err := effect.sendControl(snapshot); err != nil {
223+ return err
224+ }
225+ // The client now observes this revision: commit the keys and projections
226+ // its selection will validate against. A concurrent close or replacement
227+ // interaction owns a newer namespace, so a superseded commit is dropped
228+ // instead of reviving the retired source.
229+ ac.overlays.pickerMu.Lock()
230+ if ac.overlays.pickerOpen && ac.overlays.pickerInteraction == interaction {
231+ ac.overlays.pickerRevisions[servingPickerSourceID] = revision
232+ ac.overlays.pickerKeys = groupedSet.keys
233+ ac.overlays.pickerSourcePublished = true
234+ ac.overlays.pickerRecent = recent
235+ ac.overlays.pickerGrouped = grouped
236+ }
237+ ac.overlays.pickerMu.Unlock()
238+ return nil
239+}
240+
241+// setProjection presents one resolved line set as a publishable projection.
242+func setProjection(set pickerLineSet) protocol.PickerProjection {
243+ return protocol.PickerProjection{Lines: set.lines, Cursor: set.cursor}
244+}
245+
246+// pickerProjectionEqual reports whether two published projections present the
247+// same authoritative facts. Order stays significant: a reordering changes the
248+// row a cursor index names, which is authority the client must observe.
249+func pickerProjectionEqual(left, right protocol.PickerProjection) bool {
250+ return left.Cursor == right.Cursor && slices.Equal(left.Lines, right.Lines)
251+}
252+
253+// pickerLineSetHasMove reports whether a move line set offers any destination,
254+// which is what failed move entry must report instead of opening an empty
255+// picker.
256+func pickerLineSetHasMove(set pickerLineSet) bool {
257+ for _, line := range set.lines {
258+ if line.Actions&protocol.PickerCanMove != 0 {
259+ return true
260+ }
261+ }
262+ return false
263+}
264+
265+// refreshPickerSnapshot republishes the serving line set after its rows, the
266+// attachment view, or the size changed. It derives a fresh effect from the
267+// current attachment capability: the effect that opened the interaction is
268+// bounded to its own operation and may already be retired. A closed
269+// interaction is a no-op.
270+func (d *Daemon) refreshPickerSnapshot(ac *attachedClient) {
271+ open, interaction, _, _, _ := ac.pickerState()
272+ if !open || interaction == 0 {
273+ return
274+ }
275+ sess := ac.currentAttachmentSession()
276+ if sess == nil {
277+ return
278+ }
279+ current := ac.transportSnapshot()
280+ _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport)
281+ if !admitted {
282+ return
283+ }
284+ defer effect.End()
285+ _ = d.publishPickerSourceForAttachment(ac, effect, interaction)
286+ if sess := ac.currentAttachmentSession(); sess != nil {
287+ d.invalidateRender(sess, ac, false, "picker.go:refresh")
288+ }
289+}
290+
291+// closePickerForAttachment retires the interaction and confirms it to the
292+// client with the daemon output boundary the client must display before it
293+// releases the terminal. Stale closes skip so generations stay ordered. The
294+// boolean reports whether this call retired the interaction.
295+func (d *Daemon) closePickerForAttachment(ac *attachedClient, effect *attachmentEffect, interaction uint64) bool {
296+ if ac == nil || ac.overlays == nil || interaction == 0 {
297+ return false
298+ }
299+ ac.overlays.pickerMu.Lock()
300+ if !ac.overlays.pickerOpen || ac.overlays.pickerInteraction != interaction {
301+ ac.overlays.pickerMu.Unlock()
302+ return false
303+ }
304+ previewSession := ac.overlays.pickerPreviewSession
305+ previewGeneration := ac.overlays.pickerPreviewGeneration
306+ ac.overlays.pickerOpen = false
307+ ac.overlays.pickerKeys = nil
308+ ac.overlays.pickerRevisions = nil
309+ ac.overlays.pickerSourcePublished = false
310+ ac.overlays.pickerRecent = protocol.PickerProjection{}
311+ ac.overlays.pickerGrouped = protocol.PickerProjection{}
312+ ac.overlays.pickerPreviewGeneration = 0
313+ ac.overlays.pickerPreviewKey = ""
314+ ac.overlays.pickerPreviewSession = nil
315+ ac.overlays.pickerMu.Unlock()
316+ // A retired interaction stops observing: its row is no longer displayed,
317+ // and a later preview must not resurrect it. The recorded session pins the
318+ // teardown to the exact coordinator that owns the subscription.
319+ d.teardownPickerPreviewSubscription(ac, previewSession, previewGeneration)
320+ if effect == nil {
321+ return true
322+ }
323+ barrierEpoch, barrierState, _ := pickerClientBarrier(ac)
324+ closed := protocol.PickerClosed{InteractionID: interaction, BarrierEpoch: barrierEpoch, BarrierState: barrierState}
325+ if protocol.ValidatePickerClosed(closed) != nil {
326+ return true
327+ }
328+ _ = effect.sendControl(closed)
329+ return true
330+}
331+
332+// resolvePickerSelection validates one typed selection against the open
333+// interaction and performs the action the owning source authorised. Unknown
334+// keys, foreign sources, and stale revisions reject with a bounded failure.
335+func (d *Daemon) resolvePickerSelection(effect *attachmentEffect, selection protocol.PickerSelection) {
336+ if protocol.ValidatePickerSelection(selection) != nil || effect == nil {
337+ return
338+ }
339+ ac := effect.ac
340+ if ac == nil || ac.overlays == nil {
341+ return
342+ }
343+ ac.overlays.pickerMu.Lock()
344+ open := ac.overlays.pickerOpen
345+ interaction := ac.overlays.pickerInteraction
346+ intent := ac.overlays.pickerIntent
347+ source := ac.overlays.pickerMoveSource
348+ revision := ac.overlays.pickerRevisions[selection.SourceID]
349+ target, ok := ac.overlays.pickerKeys[selection.Key]
350+ ac.overlays.pickerMu.Unlock()
351+ if !open || interaction != selection.InteractionID {
352+ d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
353+ return
354+ }
355+ if selection.SourceID != servingPickerSourceID {
356+ d.sendPickerFailure(effect, selection, protocol.PickerUnknownSource)
357+ return
358+ }
359+ if selection.SourceRevision != revision {
360+ d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
361+ return
362+ }
363+ if !ok {
364+ d.sendPickerFailure(effect, selection, protocol.PickerUnknownKey)
365+ return
366+ }
367+ // Navigation commits through the handoff, which must not accept a target
368+ // whose lifecycle already moved on. Move and kill revalidate source and
369+ // destination themselves (move_lifecycle.go / killPickerTarget), so they
370+ // report their own precise rejection instead of a generic retire.
371+ if selection.Action == protocol.PickerActionNavigate && !d.pickerTargetCurrent(target) {
372+ d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
373+ return
374+ }
375+ switch selection.Action {
376+ case protocol.PickerActionKill:
377+ d.resolvePickerKill(effect, ac, interaction, target, selection)
378+ case protocol.PickerActionMove:
379+ d.resolvePickerMove(effect, ac, interaction, intent, source, target, selection)
380+ default:
381+ d.resolvePickerNavigate(effect, ac, interaction, target, selection)
382+ }
383+}
384+
385+// resolvePickerNavigate retires the interaction before the handoff. The
386+// client's release waits for the daemon's own close, and the handoff's
387+// destination paint has to be that paint: closing afterwards would let it be
388+// suppressed while the client still owned the terminal.
389+func (d *Daemon) resolvePickerNavigate(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
390+ if !d.closePickerForAttachment(ac, effect, interaction) {
391+ d.sendPickerFailure(effect, selection, protocol.PickerStaleRevision)
392+ return
393+ }
394+ if err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "picker-select"); err != nil {
395+ d.sendPickerFailure(effect, selection, protocol.PickerNavigationFailed)
396+ // The interaction is already retired: the client drains until an
397+ // authoritative paint, so publish one even though the handoff failed.
398+ d.invalidateRender(effect.sess, ac, true, "picker-select-failed")
399+ }
400+}
401+
402+// resolvePickerMove closes before committing a composite move+follow. The
403+// calling effect must end before the transaction freezes attachment admission.
404+func (d *Daemon) resolvePickerMove(effect *attachmentEffect, ac *attachedClient, interaction uint64, intent protocol.PickerIntent, source moveSourceLocator, target picker.Target, selection protocol.PickerSelection) {
405+ sess := effect.sess
406+ if err := d.movePickerSourceError(source); err != nil {
407+ d.sendPickerFailure(effect, selection, protocol.PickerRetiredTarget)
408+ d.reportAttachmentError(sess, movePickerUserError(err))
409+ d.refreshPickerSnapshot(ac)
410+ return
411+ }
412+ closed := false
413+ beforeFollow := func() error {
414+ if !d.closePickerForAttachment(ac, effect, interaction) {
415+ return errMoveStaleTarget
416+ }
417+ closed = true
418+ effect.End()
419+ return nil
420+ }
421+ // A composite follow reports completion from the move postcommit, which
422+ // admits a fresh effect on the published destination capability once the
423+ // committing effect has ended and the destination paint is authoritative.
424+ afterFollow := func(published *attachmentEffect) {
425+ d.sendPickerResult(published, selection, interaction)
426+ }
427+ // Non-following pane moves retain the open interaction. The transaction
428+ // calls beforeFollow only after admission proves that it will follow.
429+ defer effect.End()
430+ if err := d.commitMovePickerSelection(intent, source, target, beforeFollow, afterFollow); err != nil {
431+ if closed {
432+ // The committing effect already ended so the transaction could drain
433+ // it. Reject and repaint through the attachment's current capability
434+ // instead of reusing an effect that is no longer admitted.
435+ d.reportAttachmentError(sess, movePickerUserError(err))
436+ d.rejectClosedPickerMove(ac, selection)
437+ } else {
438+ d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
439+ d.reportAttachmentError(sess, movePickerUserError(err))
440+ d.refreshPickerSnapshot(ac)
441+ }
442+ return
443+ }
444+ if !closed {
445+ d.sendPickerResult(effect, selection, interaction)
446+ d.refreshPickerSnapshot(ac)
447+ }
448+}
449+
450+// rejectClosedPickerMove delivers bounded rejection feedback and an
451+// authoritative repaint after a composite move closed the picker and ended its
452+// committing effect. It never sends through that ended effect: a fresh effect
453+// on the attachment's current capability carries the failure, and the paint
454+// releases the client's post-close drain.
455+func (d *Daemon) rejectClosedPickerMove(ac *attachedClient, selection protocol.PickerSelection) {
456+ sess := ac.currentAttachmentSession()
457+ if sess == nil {
458+ return
459+ }
460+ d.invalidateRender(sess, ac, true, "picker-move-failed")
461+ _, effect, admitted := ac.beginCurrentAttachmentEffect(sess, ac.transport())
462+ if !admitted {
463+ return
464+ }
465+ d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
466+ effect.End()
467+}
468+
469+// resolvePickerKill destroys the target and keeps the picker open.
470+func (d *Daemon) resolvePickerKill(effect *attachmentEffect, ac *attachedClient, interaction uint64, target picker.Target, selection protocol.PickerSelection) {
471+ // The kill holds this admission until it finishes, so a replacement
472+ // initiator cannot make the destructive action look unattributed.
473+ effect.bindActionEnd(d, "picker-delete")
474+ if err := d.killPickerTargetForAttachment(target, effect); err != nil {
475+ d.sendPickerFailure(effect, selection, protocol.PickerActionFailed)
476+ d.refreshPickerSnapshot(ac)
477+ return
478+ }
479+ d.sendPickerResult(effect, selection, interaction)
480+ d.refreshPickerSnapshot(ac)
481+}
482+
483+// pickerTargetCurrent revalidates a resolved target for lifecycle freshness:
484+// incarnation pin plus expected-created-at, mirroring previewTarget and
485+// targetMatchesLifecycle. A renamed-or-recreated session between snapshot and
486+// commit rejects as retired, never force-commits.
487+func (d *Daemon) pickerTargetCurrent(target picker.Target) bool {
488+ // A remote target names an endpoint-qualified remote session, not a local
489+ // one: resolving it against the local session table would always miss and
490+ // report a retired target. Its exact route is revalidated against the remote
491+ // catalogue by the handoff itself (remoteCatalogTargetReady), which reports
492+ // its own precise rejection.
493+ if target.RemoteTarget != nil || target.RemoteKey != nil {
494+ return true
495+ }
496+ if target.Name == "" {
497+ d.mu.Lock()
498+ sess := d.sessions[target.Session]
499+ d.mu.Unlock()
500+ if sess == nil {
501+ return false
502+ }
503+ sess.mu.Lock()
504+ defer sess.mu.Unlock()
505+ return targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation)
506+ }
507+ d.mu.Lock()
508+ defer d.mu.Unlock()
509+ _, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
510+ if !ok {
511+ return false
512+ }
513+ if isStopped {
514+ return targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation)
515+ }
516+ return true
517+}
518+
519+// pickerClientBarrier snapshots the acquisition boundary for one interaction
520+// offer or close: the output fence epoch, the latest committed state, and the
521+// view revision that doubles as the size epoch. It reads viewMu-protected
522+// state only (never pane/session locks, never sendMu — see the lock-order
523+// notes at the top of client.go); callers must not hold pickerMu.
524+func pickerClientBarrier(ac *attachedClient) (epoch, state, sizeEpoch uint64) {
525+ if ac == nil {
526+ return 0, 0, 0
527+ }
528+ view := ac.viewSnapshot()
529+ if ac.output != nil {
530+ epoch = ac.output.currentEpoch()
531+ state = ac.output.committedState()
532+ }
533+ return epoch, state, view.revision
534+}
535+
536+func (d *Daemon) sendPickerResult(effect *attachmentEffect, selection protocol.PickerSelection, interaction uint64) {
537+ result := protocol.PickerResult{
538+ CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
539+ InteractionID: interaction, SourceID: selection.SourceID, Key: selection.Key, Action: selection.Action,
540+ }
541+ if protocol.ValidatePickerResult(result) != nil {
542+ return
543+ }
544+ _ = effect.sendControl(result)
545+}
546+
547+func (d *Daemon) sendPickerFailure(effect *attachmentEffect, selection protocol.PickerSelection, code protocol.PickerFailureCode) {
548+ failure := protocol.PickerFailure{
549+ CauseActionID: selection.CauseActionID, RequestID: selection.RequestID,
550+ InteractionID: selection.InteractionID, SourceID: selection.SourceID,
551+ Key: selection.Key, Action: selection.Action, Code: code,
552+ }
553+ if protocol.ValidatePickerFailure(failure) != nil {
554+ return
555+ }
556+ _ = effect.sendControl(failure)
557+}
A internal/usecase/daemon/picker_interaction_test.go +860 -0
@@ -0,0 +1,860 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/wire"
16+ "github.com/bnema/vev/internal/usecase/ui"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+// pickerClientTestUnit builds a fixture attachment and one extra session so
21+// snapshots are non-empty. It returns an admitted effect for the guarded
22+// sends.
23+func pickerClientTestUnit(t *testing.T) (*Daemon, *session, *attachedClient, chan wire.Frame, *attachmentEffect) {
24+ t.Helper()
25+ p, releasePTY := newBlockingPTY(t)
26+ return pickerClientTestUnitWithPTY(t, p, releasePTY)
27+}
28+
29+// pickerClientTestUnitWithPTY builds the same fixture over a caller-owned
30+// PTY, so tests can observe whether session input reached the child.
31+func pickerClientTestUnitWithPTY(t *testing.T, p ports.PTY, releasePTY func()) (*Daemon, *session, *attachedClient, chan wire.Frame, *attachmentEffect) {
32+ t.Helper()
33+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
34+ t.Cleanup(releasePTY)
35+ d.ptys = newFactorySeq(t, newQuietPTY())
36+ _, err := createSessionForTest(d, "second", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
37+ require.NoError(t, err)
38+ effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
39+ require.True(t, ok)
40+ t.Cleanup(effect.End)
41+ return d, sess, ac, sends, effect
42+}
43+
44+func openTestPicker(t *testing.T, d *Daemon, ac *attachedClient, effect *attachmentEffect, sends chan wire.Frame, intent protocol.PickerIntent) protocol.PickerOffer {
45+ t.Helper()
46+ require.NoError(t, d.openPickerForAttachment(ac, effect, intent, moveSourceLocator{}, 0))
47+ offer := awaitPickerOffer(t, sends)
48+ // The interaction namespace lives on the attachment, so the effect that
49+ // carried the offer is released here: a later transition or move must be
50+ // able to drain every other effect on this attachment.
51+ effect.End()
52+ return offer
53+}
54+
55+func awaitPickerOffer(t *testing.T, sends chan wire.Frame) protocol.PickerOffer {
56+ t.Helper()
57+ for {
58+ frame := awaitTestValue(t, sends, "picker offer was not published")
59+ if frame.Type != wire.MsgPickerOffer {
60+ continue
61+ }
62+ offer, err := wire.UnmarshalPickerOffer(frame.Payload)
63+ require.NoError(t, err)
64+ return offer
65+ }
66+}
67+
68+func awaitPickerSnapshot(t *testing.T, sends chan wire.Frame) protocol.PickerSnapshot {
69+ t.Helper()
70+ for {
71+ frame := awaitTestValue(t, sends, "picker snapshot was not published")
72+ if frame.Type != wire.MsgPickerSnapshot {
73+ continue
74+ }
75+ snapshot, err := wire.UnmarshalPickerSnapshot(frame.Payload)
76+ require.NoError(t, err)
77+ return snapshot
78+ }
79+}
80+
81+func awaitPickerFailure(t *testing.T, sends chan wire.Frame) protocol.PickerFailure {
82+ t.Helper()
83+ for {
84+ frame := awaitTestValue(t, sends, "picker failure was not published")
85+ if frame.Type != wire.MsgPickerFailure {
86+ continue
87+ }
88+ failure, err := wire.UnmarshalPickerFailure(frame.Payload)
89+ require.NoError(t, err)
90+ return failure
91+ }
92+}
93+
94+func pickerClientCapability(t *testing.T, ac *attachedClient, sess *session) attachmentCapability {
95+ t.Helper()
96+ return captureAttachmentCapability(sess, ac, ac.transport())
97+}
98+
99+// navigateSelection builds one typed commit for the displayed snapshot.
100+func navigateSelection(snapshot protocol.PickerSnapshot, key string) protocol.PickerSelection {
101+ return protocol.PickerSelection{
102+ InteractionID: snapshot.InteractionID, SourceID: servingPickerSourceID,
103+ SourceRevision: snapshot.SourceRevision, Key: key, Action: protocol.PickerActionNavigate,
104+ }
105+}
106+
107+// firstSelectableKey returns the first line the source authorised for the
108+// requested action.
109+func firstSelectableKey(t *testing.T, snapshot protocol.PickerSnapshot, action protocol.PickerLineActions) string {
110+ t.Helper()
111+ for _, line := range snapshot.Lines {
112+ if line.Actions&action != 0 {
113+ return line.Key
114+ }
115+ }
116+ t.Fatalf("snapshot published no line admitting %d", action)
117+ return ""
118+}
119+
120+func TestPickerOfferAndSnapshotPublishResolvableLines(t *testing.T) {
121+ d, _, ac, sends, effect := pickerClientTestUnit(t)
122+
123+ offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
124+ snapshot := awaitPickerSnapshot(t, sends)
125+
126+ require.NotZero(t, offer.InteractionID)
127+ require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
128+ require.Equal(t, offer.InteractionID, snapshot.InteractionID)
129+ require.Equal(t, servingPickerSourceID, snapshot.SourceID)
130+ require.Equal(t, uint64(1), snapshot.SourceRevision)
131+ require.NotEmpty(t, snapshot.Lines)
132+
133+ // Every selectable line resolves through the published keys, and the keys
134+ // carry session names for the unchanged handoff.
135+ ac.overlays.pickerMu.Lock()
136+ keys := ac.overlays.pickerKeys
137+ ac.overlays.pickerMu.Unlock()
138+ selectable := 0
139+ for _, line := range snapshot.Lines {
140+ if line.Actions == 0 {
141+ continue
142+ }
143+ selectable++
144+ target, ok := keys[line.Key]
145+ require.True(t, ok, "line %q has no resolved target", line.Key)
146+ require.NotEmpty(t, target.Name, "resolved target carries no session name")
147+ }
148+ require.NotZero(t, selectable)
149+}
150+
151+func TestPickerSnapshotPublishesFocusEligibility(t *testing.T) {
152+ d, _, ac, sends, effect := pickerClientTestUnit(t)
153+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
154+ snapshot := awaitPickerSnapshot(t, sends)
155+
156+ headers, tabs := 0, 0
157+ for _, line := range snapshot.Lines {
158+ switch line.Kind {
159+ case protocol.PickerLineSection:
160+ require.False(t, line.Focusable, "section %q is not a cursor destination", line.Label)
161+ require.Zero(t, line.Actions, "section %q admits no action", line.Label)
162+ case protocol.PickerLineSession:
163+ headers++
164+ // A live session header names a group of tabs: navigation belongs
165+ // to its tab rows, so the header is rendered and skipped.
166+ require.False(t, line.Focusable, "session header %q is not a destination", line.Label)
167+ require.Zero(t, line.Actions)
168+ case protocol.PickerLineTab:
169+ tabs++
170+ require.True(t, line.Focusable, "tab %q must be reachable", line.Label)
171+ require.NotZero(t, line.Actions, "a reachable tab admits the action that made it reachable")
172+ }
173+ }
174+ require.NotZero(t, headers)
175+ require.NotZero(t, tabs)
176+
177+ // The published cursor hint never names a row the client may not rest on.
178+ require.NotEmpty(t, snapshot.Cursor.Key)
179+ for _, line := range snapshot.Lines {
180+ if line.Key == snapshot.Cursor.Key {
181+ require.True(t, line.Focusable)
182+ }
183+ }
184+}
185+
186+func TestRemotePickerHostRowStaysReachableWithoutCommitting(t *testing.T) {
187+ d := newRemotePickerDaemon()
188+ seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{
189+ Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable,
190+ InventoryKnown: true, LastSuccess: time.Unix(10, 0),
191+ })
192+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
193+ token := sess.captureAttachmentCapability(ac, ac.transport())
194+ effect, admitted := ac.beginAttachmentEffect(token)
195+ require.True(t, admitted)
196+ defer effect.End()
197+
198+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
199+ _ = awaitPickerOffer(t, sends)
200+ snapshot := awaitPickerSnapshot(t, sends)
201+
202+ hostRows := 0
203+ for _, line := range snapshot.Lines {
204+ if line.Kind != protocol.PickerLineHost {
205+ continue
206+ }
207+ hostRows++
208+ require.True(t, line.Focusable, "a host status row stays reachable for inspection")
209+ require.Zero(t, line.Actions, "a host status row never commits")
210+ }
211+ require.NotZero(t, hostRows, "an unreachable host publishes a status row")
212+}
213+
214+func TestPickerOpenInstallsNoPresentationModel(t *testing.T) {
215+ d, _, ac, sends, effect := pickerClientTestUnit(t)
216+
217+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
218+ _ = awaitPickerSnapshot(t, sends)
219+
220+ // The daemon publishes data and actions only: no model, no cursor, no
221+ // search state of its own.
222+ ac.overlays.pickerMu.Lock()
223+ require.True(t, ac.overlays.pickerOpen)
224+ require.NotZero(t, ac.overlays.pickerInteraction)
225+ require.NotEmpty(t, ac.overlays.pickerKeys)
226+ ac.overlays.pickerMu.Unlock()
227+}
228+
229+func TestPickerSelectionRejectsStaleAndUnknown(t *testing.T) {
230+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
231+
232+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
233+ snapshot := awaitPickerSnapshot(t, sends)
234+
235+ capability := pickerClientCapability(t, ac, sess)
236+ // Unknown key on the open interaction reports UnknownKey without
237+ // closing the interaction.
238+ require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, "ff00/ghost")))
239+ failure := awaitPickerFailure(t, sends)
240+ require.Equal(t, protocol.PickerUnknownKey, failure.Code)
241+ require.Equal(t, snapshot.InteractionID, failure.InteractionID)
242+
243+ // A foreign source is rejected before any key lookup.
244+ foreign := navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))
245+ foreign.SourceID = "elsewhere"
246+ require.False(t, d.handleAttachmentClientMessage(capability, foreign))
247+ require.Equal(t, protocol.PickerUnknownSource, awaitPickerFailure(t, sends).Code)
248+
249+ // A retired interaction reports StaleRevision.
250+ d.closePickerForAttachment(ac, effect, snapshot.InteractionID)
251+ require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, firstSelectableKey(t, snapshot, protocol.PickerCanNavigate))))
252+ require.Equal(t, protocol.PickerStaleRevision, awaitPickerFailure(t, sends).Code)
253+}
254+
255+func TestPickerMoveIntentPublishesMoveLinesOnly(t *testing.T) {
256+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
257+ source := moveSourceLocator{
258+ Session: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
259+ TabID: domain.TabStableID(testAttachmentTab(sess).stableID),
260+ }
261+ ac.overlays.pickerMu.Lock()
262+ require.False(t, ac.overlays.pickerOpen)
263+ ac.overlays.pickerMu.Unlock()
264+
265+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentMoveTab, source, 0))
266+ offer := awaitPickerOffer(t, sends)
267+ snapshot := awaitPickerSnapshot(t, sends)
268+ require.Equal(t, protocol.PickerIntentMoveTab, offer.Intent)
269+
270+ // The source session never appears as its own destination, and rows that
271+ // cannot accept a move carry no action.
272+ for _, line := range snapshot.Lines {
273+ require.NotEqual(t, sourceMoveKey(source), line.Key)
274+ if line.Actions != 0 {
275+ require.Equal(t, protocol.PickerCanMove, line.Actions)
276+ }
277+ }
278+}
279+
280+func sourceMoveKey(source moveSourceLocator) string {
281+ return pickerMoveSourceKey(source)
282+}
283+
284+func TestPickerOpenRepaintsWithoutSessionChange(t *testing.T) {
285+ d, _, ac, sends, effect := pickerClientTestUnit(t)
286+
287+ // The open invalidates the session even when nothing else changed:
288+ // without this repaint the opener's fence would never retire and
289+ // the palette Enter would hang until its deadline.
290+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
291+ _ = awaitPickerSnapshot(t, sends)
292+ select {
293+ case frame := <-sends:
294+ require.Equal(t, wire.MsgOutput, frame.Type, "open must trigger an authoritative repaint")
295+ default:
296+ t.Fatal("open produced no repaint after the snapshot")
297+ }
298+}
299+
300+func TestPickerSessionPickerCommandOpensInteraction(t *testing.T) {
301+ p, release := newBlockingPTY(t)
302+ defer release()
303+ d, current, ac, sends := newManualSessionWithPTYs(t, p)
304+ effect := beginRecentRoutePaletteEffect(t, d, current, ac)
305+ effect.uiActionID = 77
306+
307+ // Full path through overlay dispatch: palette query, then Enter on
308+ // the same effect. The Enter routes through handlePaletteInput via
309+ // the overlay runtime, exactly like a real client frame.
310+ d.enterPalette(current, ac)
311+ d.handlePaletteInput(ac, []byte("sessionpicker"), effect)
312+ require.NotNil(t, ac.overlays.palette)
313+ d.handlePaletteInput(ac, []byte("\r"), effect)
314+
315+ offer := awaitPickerOffer(t, sends)
316+ snapshot := awaitPickerSnapshot(t, sends)
317+ require.NotZero(t, offer.InteractionID)
318+ require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
319+ require.NotEmpty(t, snapshot.Lines)
320+ ac.overlays.pickerMu.Lock()
321+ require.True(t, ac.overlays.pickerOpen)
322+ ac.overlays.pickerMu.Unlock()
323+}
324+
325+func TestPickerCancelPublishesClosedAndAuthoritativeFullPaint(t *testing.T) {
326+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
327+
328+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
329+ snapshot := awaitPickerSnapshot(t, sends)
330+ // The open publishes its own restore paint after the snapshot; the release
331+ // assertion below is about the paint that follows the close.
332+ drainAllFrames(sends)
333+
334+ // The client cancels: the daemon retires the interaction, confirms the
335+ // close with its restore barrier, and publishes the authoritative full
336+ // paint the client's release path waits for.
337+ capability := pickerClientCapability(t, ac, sess)
338+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
339+ ac.overlays.pickerMu.Lock()
340+ require.False(t, ac.overlays.pickerOpen)
341+ ac.overlays.pickerMu.Unlock()
342+
343+ var sawClosed bool
344+ for settled := false; !settled; {
345+ select {
346+ case frame := <-sends:
347+ if frame.Type == wire.MsgPickerClosedServer {
348+ closed, err := wire.UnmarshalPickerClosed(frame.Payload)
349+ require.NoError(t, err)
350+ require.Equal(t, snapshot.InteractionID, closed.InteractionID)
351+ sawClosed = true
352+ continue
353+ }
354+ if frame.Type == wire.MsgOutput {
355+ output, err := wire.UnmarshalOutput(frame.Payload)
356+ require.NoError(t, err)
357+ require.True(t, sawClosed, "the restore paint must follow the close")
358+ require.True(t, output.Full, "cancel release paint must be authoritative")
359+ settled = true
360+ }
361+ case <-time.After(200 * time.Millisecond):
362+ settled = true
363+ }
364+ }
365+ require.True(t, sawClosed, "cancel published no close confirmation")
366+}
367+
368+func TestPickerPaletteFramesOpenInteraction(t *testing.T) {
369+ p, release := newBlockingPTY(t)
370+ defer release()
371+ d, _, ac, sends := newManualSessionWithPTYs(t, p)
372+ capability := pickerClientCapability(t, ac, testAttachmentSession(t, ac))
373+
374+ // Drive the real attached-client frame path instead of calling the
375+ // palette handler: Alt+Space opens the palette, the query selects the
376+ // session-picker command, and Enter executes it.
377+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 4, Data: []byte("\x1b ")}))
378+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 5, Data: []byte("SSP")}))
379+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 6, Data: []byte("\r")}))
380+
381+ _ = awaitPickerOffer(t, sends)
382+ snapshot := awaitPickerSnapshot(t, sends)
383+ require.NotZero(t, snapshot.InteractionID)
384+ require.NotEmpty(t, snapshot.Lines)
385+ ac.overlays.pickerMu.Lock()
386+ require.True(t, ac.overlays.pickerOpen)
387+ ac.overlays.pickerMu.Unlock()
388+}
389+
390+func testAttachmentSession(t *testing.T, ac *attachedClient) *session {
391+ t.Helper()
392+ sess := ac.currentAttachmentSession()
393+ require.NotNil(t, sess)
394+ return sess
395+}
396+
397+// secondSessionForTest returns the fixture's extra session, the one the attach
398+// itself does not display.
399+func secondSessionForTest(t *testing.T, d *Daemon) *session {
400+ t.Helper()
401+ d.mu.Lock()
402+ defer d.mu.Unlock()
403+ for _, sess := range d.sessions {
404+ if sess == nil {
405+ continue
406+ }
407+ sess.mu.Lock()
408+ name := sess.name
409+ sess.mu.Unlock()
410+ if name == "second" {
411+ return sess
412+ }
413+ }
414+ t.Fatal("fixture second session missing")
415+ return nil
416+}
417+
418+// pickerBellLines reports the session headers carrying the bell glyph and the
419+// tab rows carrying the structured attention marker.
420+func pickerBellLines(snapshot protocol.PickerSnapshot) (headers int, tabRows int) {
421+ bell := string(ui.AttentionGlyph)
422+ for _, line := range snapshot.Lines {
423+ switch line.Kind {
424+ case protocol.PickerLineSession:
425+ if strings.HasSuffix(line.Label, " "+bell) {
426+ headers++
427+ }
428+ case protocol.PickerLineTab:
429+ if line.Attention {
430+ tabRows++
431+ }
432+ }
433+ }
434+ return headers, tabRows
435+}
436+
437+// TestPickerSnapshotCarriesAttentionBellsToTheClient pins the notification path
438+// into the client-owned modal: the daemon publishes the bell on the ringing
439+// session's header and the structured marker on its tab row, and the client
440+// renders both.
441+func TestPickerSnapshotCarriesAttentionBellsToTheClient(t *testing.T) {
442+ d, _, ac, sends, effect := pickerClientTestUnit(t)
443+ ringing := secondSessionForTest(t, d)
444+ ringing.mu.Lock()
445+ ringing.tabs[0].attention = true
446+ ringing.tabs[0].attentionAt = time.Unix(1, 0)
447+ ringing.mu.Unlock()
448+
449+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
450+ snapshot := awaitPickerSnapshot(t, sends)
451+
452+ headers, tabRows := pickerBellLines(snapshot)
453+ require.Equal(t, 1, headers, "the ringing session header must carry the bell glyph")
454+ require.Equal(t, 1, tabRows, "the ringing tab row must carry the structured attention marker")
455+}
456+
457+// TestAttentionPulseRepublishesTheOpenPickerSource pins that a bell raised
458+// while the client presents the modal reaches it: client-owned presentation
459+// suppresses the daemon's paints, so the pulse must advance the picker source
460+// the client presents from. A closed interaction republishes nothing.
461+func TestAttentionPulseRepublishesTheOpenPickerSource(t *testing.T) {
462+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
463+ defer releases[0]()
464+ defer releases[1]()
465+ clk := newManualAttentionClock()
466+ d.clock = clk
467+ require.True(t, selectTestAttachmentTab(sess, 0))
468+
469+ effect := admitPickerEffectForTest(t, sess, ac)
470+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
471+ require.Equal(t, protocol.PickerIntentNavigation, awaitPickerOffer(t, sends).Intent)
472+ first := awaitPickerSnapshot(t, sends)
473+ require.Equal(t, 0, pickerBellCount(first), "a quiet session must publish no bell")
474+
475+ ctx, cancel := context.WithCancel(context.Background())
476+ defer cancel()
477+ done := make(chan struct{})
478+ go func() {
479+ d.attentionAnimator(ctx)
480+ close(done)
481+ }()
482+
483+ // A background tab rings while the modal is open. The client owns
484+ // presentation, so only a newer source revision can update its rows.
485+ sess.mu.Lock()
486+ sess.tabs[1].attention = true
487+ sess.tabs[1].attentionAt = time.Unix(1, 0)
488+ sess.mu.Unlock()
489+ d.pokeAttentionTicker()
490+
491+ timer := clk.nextTimer(t)
492+ timer.fire()
493+ second := awaitPickerSnapshot(t, sends)
494+ require.Greater(t, second.SourceRevision, first.SourceRevision)
495+ headers, tabRows := pickerBellLines(second)
496+ require.Equal(t, 1, headers, "the pulse must publish the new bell on the session header")
497+ require.Equal(t, 1, tabRows, "the pulse must publish the new bell on the tab row")
498+
499+ // A retired interaction must not be reopened by a later pulse. The
500+ // animator keeps one timer while attention exists, so the same fired timer
501+ // carries the next pulse.
502+ retireEffect := admitPickerEffectForTest(t, sess, ac)
503+ require.True(t, d.closePickerForAttachment(ac, retireEffect, second.InteractionID))
504+ _ = drainAllFrames(sends)
505+ sess.mu.Lock()
506+ sess.tabs[0].attention = true
507+ sess.tabs[0].attentionAt = time.Unix(2, 0)
508+ sess.mu.Unlock()
509+ d.pokeAttentionTicker()
510+ timer.fire()
511+ for _, frame := range drainAllFrames(sends) {
512+ require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "a closed interaction republished a snapshot")
513+ }
514+
515+ cancel()
516+ require.Eventually(t, func() bool {
517+ select {
518+ case <-done:
519+ return true
520+ default:
521+ return false
522+ }
523+ }, time.Second, time.Millisecond)
524+}
525+
526+func pickerBellCount(snapshot protocol.PickerSnapshot) int {
527+ headers, tabRows := pickerBellLines(snapshot)
528+ return headers + tabRows
529+}
530+
531+func TestPickerSelectionRequiresExactSourceRevision(t *testing.T) {
532+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
533+
534+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
535+ first := awaitPickerSnapshot(t, sends)
536+ // A refresh that observes a real row change publishes a newer revision: the
537+ // client may only commit the model it is displaying, so an older revision
538+ // must fail even though it is not newer than the daemon's current one.
539+ _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
540+ require.NoError(t, err)
541+ d.refreshPickerSnapshot(ac)
542+ second := awaitPickerSnapshot(t, sends)
543+ require.Greater(t, second.SourceRevision, first.SourceRevision)
544+
545+ capability := pickerClientCapability(t, ac, sess)
546+ key := firstSelectableKey(t, second, protocol.PickerCanNavigate)
547+ for _, revision := range []uint64{second.SourceRevision - 1, second.SourceRevision + 1} {
548+ selection := navigateSelection(second, key)
549+ selection.SourceRevision = revision
550+ require.False(t, d.handleAttachmentClientMessage(capability, selection))
551+ failure := awaitPickerFailure(t, sends)
552+ require.Equal(t, protocol.PickerStaleRevision, failure.Code, "revision %d must be rejected", revision)
553+ ac.overlays.pickerMu.Lock()
554+ require.True(t, ac.overlays.pickerOpen, "a rejected revision must not close the interaction")
555+ ac.overlays.pickerMu.Unlock()
556+ }
557+}
558+
559+func TestPickerInteractionDropsRawInput(t *testing.T) {
560+ writes := make(chan []byte, 8)
561+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
562+ d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
563+ capability := pickerClientCapability(t, ac, sess)
564+
565+ // A normal attachment forwards keys to the session.
566+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: []byte("x")}))
567+ require.Equal(t, []byte("x"), awaitTestValue(t, writes, "session input never reached the pty"))
568+
569+ // Positive control: with the pane reporting mouse mode, a mouse report
570+ // reaches the pane's pty. Without this control the negative assertion
571+ // below would pass even if mouse routing were broken outright.
572+ tb := testAttachmentTab(sess)
573+ require.NotNil(t, tb)
574+ pane := tb.panes["pane-1"]
575+ require.NotNil(t, pane)
576+ pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
577+ mouseReport := []byte(fmt.Sprintf("\x1b[<0;2;%dM", clientTopBarRows+2))
578+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 1, ActionID: 1, Data: mouseReport}))
579+ require.NotEmpty(t, awaitTestValue(t, writes, "positive control: mouse never reached the pane"))
580+
581+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
582+ snapshot := awaitPickerSnapshot(t, sends)
583+
584+ // While the picker is open it owns user input: keys and mouse reports
585+ // must not reach the session, and the interaction stays open.
586+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 2, ActionID: 2, Data: []byte("hidden")}))
587+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 3, ActionID: 3, Data: mouseReport}))
588+ select {
589+ case frame := <-writes:
590+ t.Fatalf("picker-owned input reached the pty: %q", frame)
591+ case <-time.After(100 * time.Millisecond):
592+ }
593+
594+ // Closing the interaction restores normal routing, mouse included.
595+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
596+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 4, ActionID: 4, Data: []byte("visible")}))
597+ require.Equal(t, []byte("visible"), awaitTestValue(t, writes, "session input did not resume after the close"))
598+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.Input{InputSeq: 5, ActionID: 5, Data: mouseReport}))
599+ require.NotEmpty(t, awaitTestValue(t, writes, "mouse routing did not resume after the close"))
600+}
601+
602+func TestPickerSelectionClosesBeforeTheDestinationPaint(t *testing.T) {
603+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
604+
605+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
606+ snapshot := awaitPickerSnapshot(t, sends)
607+ capability := pickerClientCapability(t, ac, sess)
608+
609+ // Selecting the current session is a same-peer handoff. The interaction
610+ // must be retired before that handoff: the client releases only on a
611+ // paint accepted after the daemon's close, so the destination paint has
612+ // to follow it on the wire. Drain the open's own repaint first.
613+ drainAllFrames(sends)
614+ key := firstSelectableKey(t, snapshot, protocol.PickerCanNavigate)
615+ require.False(t, d.handleAttachmentClientMessage(capability, navigateSelection(snapshot, key)))
616+
617+ // Collect the handoff frames for a short settle window, then require the
618+ // close to precede any paint.
619+ var order []wire.MsgType
620+ for settled := false; !settled; {
621+ select {
622+ case frame := <-sends:
623+ order = append(order, frame.Type)
624+ case <-time.After(200 * time.Millisecond):
625+ settled = true
626+ }
627+ }
628+ require.NotEmpty(t, order, "the handoff published nothing")
629+ require.Equal(t, wire.MsgPickerClosedServer, order[0], "the close must precede the handoff")
630+ for i, frameType := range order {
631+ if frameType == wire.MsgOutput {
632+ require.NotZero(t, i, "a paint must not precede the close")
633+ }
634+ }
635+ ac.overlays.pickerMu.Lock()
636+ require.False(t, ac.overlays.pickerOpen)
637+ ac.overlays.pickerMu.Unlock()
638+}
639+
640+func TestPickerInteractionDropsClipboardImage(t *testing.T) {
641+ writes := make(chan []byte, 8)
642+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
643+ d, sess, ac, sends, effect := pickerClientTestUnitWithPTY(t, p, releasePTY)
644+ capability := pickerClientCapability(t, ac, sess)
645+
646+ temp := t.TempDir()
647+ t.Setenv("TMPDIR", temp)
648+ push := protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte("\x89PNG\r\n\x1a\n")}
649+
650+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
651+ snapshot := awaitPickerSnapshot(t, sends)
652+
653+ // An image push is user input too: the clipboard path must not reach the
654+ // session while the picker owns input.
655+ require.False(t, d.handleAttachmentClientMessage(capability, push))
656+ select {
657+ case frame := <-writes:
658+ t.Fatalf("clipboard path reached the pty during the interaction: %q", frame)
659+ case <-time.After(100 * time.Millisecond):
660+ }
661+
662+ // After the close the same push is delivered again.
663+ require.False(t, d.handleAttachmentClientMessage(capability, protocol.PickerClose{InteractionID: snapshot.InteractionID}))
664+ push.InputSeq = 2
665+ require.False(t, d.handleAttachmentClientMessage(capability, push))
666+ select {
667+ case frame := <-writes:
668+ require.Contains(t, string(frame), ".png")
669+ case <-time.After(2 * time.Second):
670+ t.Fatal("clipboard path did not resume after the close")
671+ }
672+}
673+
674+func TestPickerSnapshotRefreshThroughDirectoryNotification(t *testing.T) {
675+ d, _, ac, sends, effect := pickerClientTestUnit(t)
676+
677+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
678+ first := awaitPickerSnapshot(t, sends)
679+
680+ // The row set changes for real: a new session appears in the catalogue.
681+ _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
682+ require.NoError(t, err)
683+
684+ // The catalogue notification path must reach the picker even though the
685+ // daemon installs no overlay model: refreshRemoteDirectoryViews is the
686+ // entry the directory subscription calls.
687+ d.refreshRemoteDirectoryViews()
688+ second := awaitPickerSnapshot(t, sends)
689+ require.Equal(t, first.InteractionID, second.InteractionID)
690+ require.Greater(t, second.SourceRevision, first.SourceRevision)
691+ require.Greater(t, len(second.Lines), len(first.Lines), "the new session must appear as a line")
692+}
693+
694+// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections pins that the
695+// serving daemon publishes the actual Recent and Grouped projections instead of
696+// one line set copied into both. Recent carries the flat recency order the
697+// default client sort consumes; Grouped carries the sectioned order the grouped
698+// sort consumes. Both stay one authority: the same keys admit the same actions.
699+func TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections(t *testing.T) {
700+ d, _, ac, sends, effect := pickerClientTestUnit(t)
701+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
702+ snapshot := awaitPickerSnapshot(t, sends)
703+
704+ recentSections, groupedSections := 0, 0
705+ recentActions := make(map[string]protocol.PickerLineActions, len(snapshot.Recent.Lines))
706+ groupedActions := make(map[string]protocol.PickerLineActions, len(snapshot.Grouped.Lines))
707+ for _, line := range snapshot.Recent.Lines {
708+ if line.Kind == protocol.PickerLineSection {
709+ recentSections++
710+ }
711+ if line.Key != "" {
712+ recentActions[line.Key] = line.Actions
713+ }
714+ }
715+ for _, line := range snapshot.Grouped.Lines {
716+ if line.Kind == protocol.PickerLineSection {
717+ groupedSections++
718+ }
719+ if line.Key != "" {
720+ groupedActions[line.Key] = line.Actions
721+ }
722+ }
723+
724+ // The sort inputs differ: the grouped order is sectioned so its run
725+ // boundaries reach the grouped sort, and the recent order stays flat so the
726+ // recency sort never inherits grouping it did not ask for.
727+ require.Zero(t, recentSections, "recent projection published grouped section runs")
728+ require.NotZero(t, groupedSections, "grouped projection published no section runs")
729+ require.NotEqual(t, snapshot.Recent.Lines, snapshot.Grouped.Lines, "both projections carry the same sort input")
730+ require.Equal(t, recentActions, groupedActions, "projections disagree on destination actions")
731+}
732+
733+// TestPickerRefreshPublishesOnlyChangedSource pins the revision contract: a
734+// repeated publish of an unchanged projection pair neither bumps the source
735+// revision nor reaches the client, while a real row-set change republishes a
736+// full snapshot with a newer revision.
737+func TestPickerRefreshPublishesOnlyChangedSource(t *testing.T) {
738+ d, _, ac, sends, effect := pickerClientTestUnit(t)
739+
740+ openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
741+ first := awaitPickerSnapshot(t, sends)
742+ require.Equal(t, uint64(1), first.SourceRevision)
743+ _ = drainAllFrames(sends)
744+
745+ // The rows did not change, so the revision the client is displaying stays
746+ // current: a refresh must publish nothing and send nothing.
747+ d.refreshPickerSnapshot(ac)
748+ d.refreshPickerSnapshot(ac)
749+ for _, frame := range drainAllFrames(sends) {
750+ require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "unchanged source republished a snapshot")
751+ }
752+ ac.overlays.pickerMu.Lock()
753+ require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "unchanged source bumped the revision")
754+ ac.overlays.pickerMu.Unlock()
755+
756+ // A real row-set change republishes a full snapshot with a newer revision
757+ // through the current attachment capability.
758+ _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
759+ require.NoError(t, err)
760+ d.refreshPickerSnapshot(ac)
761+ second := awaitPickerSnapshot(t, sends)
762+ require.Equal(t, first.InteractionID, second.InteractionID)
763+ require.Greater(t, second.SourceRevision, first.SourceRevision)
764+ require.Greater(t, len(second.Lines), len(first.Lines))
765+
766+ // A closed interaction republishes nothing: only the close confirmation
767+ // and the authoritative repaint may follow.
768+ effect = admitPickerEffectForTest(t, testAttachmentSession(t, ac), ac)
769+ require.True(t, d.closePickerForAttachment(ac, effect, first.InteractionID))
770+ for _, frame := range drainAllFrames(sends) {
771+ require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "closed interaction republished a snapshot")
772+ }
773+}
774+
775+// pickerSnapshotFailTransport delegates to the fixture's capturing transport
776+// but rejects the next authoritative snapshot, modelling a link that drops one
777+// publication while the daemon still owns it.
778+type pickerSnapshotFailTransport struct {
779+ *mockServerConnection
780+ failNext atomic.Bool
781+}
782+
783+func (t *pickerSnapshotFailTransport) SendServer(message protocol.ServerMessage) error {
784+ if _, ok := message.(protocol.PickerSnapshot); ok && t.failNext.CompareAndSwap(true, false) {
785+ return errPickerSnapshotSend
786+ }
787+ return t.mockServerConnection.SendServer(message)
788+}
789+
790+var errPickerSnapshotSend = errors.New("picker snapshot send failed")
791+
792+// TestPickerSnapshotRetryAfterFailedSend pins the publication commit point: a
793+// snapshot whose send fails must not advance the recorded revision or replace
794+// the keys the client is still selecting against, and the identical retry must
795+// republish instead of being suppressed as unchanged.
796+func TestPickerSnapshotRetryAfterFailedSend(t *testing.T) {
797+ d, sess, ac, sends, effect := pickerClientTestUnit(t)
798+
799+ offer := openTestPicker(t, d, ac, effect, sends, protocol.PickerIntentNavigation)
800+ first := awaitPickerSnapshot(t, sends)
801+ require.Equal(t, uint64(1), first.SourceRevision)
802+
803+ // Swap in a link whose next snapshot send fails, then install it as the
804+ // attachment authority so publication effects are admitted against it.
805+ captured, ok := ac.transport().(*mockServerConnection)
806+ require.True(t, ok, "fixture transport is not the capturing mock")
807+ failTransport := &pickerSnapshotFailTransport{mockServerConnection: captured}
808+ failTransport.failNext.Store(true)
809+ ac.replaceTransport(failTransport)
810+ ac.installTestAttachmentCapability(captureAttachmentCapability(sess, ac, ac.transport()))
811+
812+ // A real row-set change is attempted while the send fails.
813+ _, err := createSessionForTest(d, "third", false, "", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
814+ require.NoError(t, err)
815+ failedEffect := admitPickerEffectForTest(t, sess, ac)
816+ failErr := d.publishPickerSourceForAttachment(ac, failedEffect, offer.InteractionID)
817+ require.ErrorIs(t, failErr, errPickerSnapshotSend)
818+ failedEffect.End()
819+
820+ // The failed publication committed nothing: the revision the client is
821+ // displaying still validates, and its keys still resolve.
822+ ac.overlays.pickerMu.Lock()
823+ require.Equal(t, first.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "failed send advanced the revision")
824+ require.True(t, ac.overlays.pickerSourcePublished, "failed send dropped the last published source")
825+ displayedKeys := ac.overlays.pickerKeys
826+ ac.overlays.pickerMu.Unlock()
827+ for _, line := range first.Lines {
828+ if line.Actions != 0 {
829+ require.Contains(t, displayedKeys, line.Key, "failed send invalidated a displayed key")
830+ }
831+ }
832+
833+ // The identical retry must reach the client with the next revision, and the
834+ // committed keys must resolve every row of that snapshot.
835+ failTransport.failNext.Store(false)
836+ retryEffect := admitPickerEffectForTest(t, sess, ac)
837+ require.NoError(t, d.publishPickerSourceForAttachment(ac, retryEffect, offer.InteractionID))
838+ second := awaitPickerSnapshot(t, sends)
839+ require.Equal(t, offer.InteractionID, second.InteractionID)
840+ require.Equal(t, first.SourceRevision+1, second.SourceRevision)
841+
842+ ac.overlays.pickerMu.Lock()
843+ require.Equal(t, second.SourceRevision, ac.overlays.pickerRevisions[servingPickerSourceID], "retry revision and client revision diverged")
844+ require.Equal(t, second.Recent, ac.overlays.pickerRecent)
845+ require.Equal(t, second.Grouped, ac.overlays.pickerGrouped)
846+ retryKeys := ac.overlays.pickerKeys
847+ ac.overlays.pickerMu.Unlock()
848+ for _, line := range second.Lines {
849+ if line.Actions != 0 {
850+ require.Contains(t, retryKeys, line.Key, "retry committed no key for a displayed row")
851+ }
852+ }
853+
854+ // A further identical publish is genuinely unchanged and must stay silent.
855+ quietEffect := admitPickerEffectForTest(t, sess, ac)
856+ require.NoError(t, d.publishPickerSourceForAttachment(ac, quietEffect, offer.InteractionID))
857+ for _, frame := range drainAllFrames(sends) {
858+ require.NotEqual(t, wire.MsgPickerSnapshot, frame.Type, "committed retry republished an unchanged source")
859+ }
860+}
A internal/usecase/daemon/picker_lines.go +495 -0
@@ -0,0 +1,495 @@
1+package daemon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/base64"
6+ "math"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/usecase/picker"
11+)
12+
13+// This file owns picker destination eligibility and the structured lines the
14+// serving daemon publishes. The daemon knows lifecycles, tab identities, and
15+// remote activation; the presenting client only renders. Keys are opaque and
16+// resolve back to picker.Target values inside the daemon.
17+
18+type pickerRemoteAvailability uint8
19+
20+const (
21+ pickerRemoteNone pickerRemoteAvailability = iota
22+ pickerRemoteCached
23+ pickerRemoteFresh
24+ pickerRemoteStale
25+ pickerRemoteVersionMismatch
26+)
27+
28+// pickerRemoteActivation is the picker action authorized by the current remote
29+// catalog snapshot. It is presentation state only; the structured remote
30+// target remains the exact route and is revalidated at commit time.
31+type pickerRemoteActivation uint8
32+
33+const (
34+ pickerRemoteUnavailable pickerRemoteActivation = iota
35+ pickerRemoteAttach
36+ pickerRemoteRestart
37+)
38+
39+// pickerSessionView is the daemon-private projection of one session or remote
40+// catalogue row used to compute destination eligibility.
41+type pickerSessionView struct {
42+ ID domain.SessionID
43+ Section string
44+ Incarnation domain.IncarnationID
45+ Name string
46+ TargetName string
47+ Tabs []pickerTabEntry
48+ Active int
49+ Stopped bool
50+ ExpectedCreatedAt *int64
51+ RemoteKey *domain.RemoteSessionKey
52+ HideRemoteOrigin bool
53+ // RemoteTarget is the structured route/lifecycle identity for picker rows.
54+ // It is never reconstructed from Name or a rendered label.
55+ RemoteTarget *domain.RemoteSessionTarget
56+ // RemoteHost marks remote host status rows that have no session key.
57+ RemoteHost string
58+ RemoteAvailability pickerRemoteAvailability
59+ RemoteDetail string
60+ RemoteReason string
61+ RemoteActivation pickerRemoteActivation
62+ // CannotAcceptMoves reports whether this session cannot receive a moved tab
63+ // or pane. False for ordinary local (and stopped) sessions; true for
64+ // restricted remote rows.
65+ CannotAcceptMoves bool
66+}
67+
68+// pickerTabEntry is one tab row; Name is drawn emphasized, Detail muted.
69+type pickerTabEntry struct {
70+ TabID domain.TabStableID // stable tab identity; independent of its current index
71+ Name string // tab display name
72+ RawName string // unformatted name used by exact remote selectors
73+ Detail string // " (paneTitle)" or "", drawn muted
74+ Attention bool // draw the attention marker right after Name, before Detail
75+}
76+
77+// pickerSourceFilter names the row the attachment currently displays.
78+type pickerSourceFilter struct {
79+ Session domain.SessionID
80+ Incarnation domain.IncarnationID
81+ TabID domain.TabStableID
82+ RemoteKey *domain.RemoteSessionKey
83+}
84+
85+// pickerLine is one published line plus its source-private target. cursor
86+// marks the row the attachment currently displays; defaultCursor marks the row
87+// a fresh interaction starts on when nothing matches the current view.
88+type pickerLine struct {
89+ line protocol.PickerLine
90+ target picker.Target
91+ cursor bool
92+ defaultCursor bool
93+}
94+
95+// pickerLineSet is one published source: ordered lines, the opaque key map,
96+// and the cursor hint.
97+type pickerLineSet struct {
98+ lines []protocol.PickerLine
99+ keys map[string]picker.Target
100+ cursor protocol.PickerCursor
101+}
102+
103+// pickerLineSetFor projects one eligibility view set into structured lines in
104+// view order (never canonicalized: order is semantically significant).
105+func pickerLineSetFor(views []pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) pickerLineSet {
106+ set := pickerLineSet{keys: make(map[string]picker.Target)}
107+ entries := make([]pickerLine, 0)
108+ for _, view := range views {
109+ if view.Section != "" {
110+ entries = append(entries, pickerLine{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: view.Section, Dim: true}})
111+ }
112+ entries = append(entries, pickerSessionLines(view, intent, source, current)...)
113+ }
114+ for i, entry := range entries {
115+ set.lines = append(set.lines, entry.line)
116+ if entry.line.Key == "" {
117+ continue
118+ }
119+ if _, dup := set.keys[entry.line.Key]; !dup {
120+ set.keys[entry.line.Key] = entry.target
121+ }
122+ if entry.cursor {
123+ set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
124+ }
125+ }
126+ if set.cursor.Key == "" {
127+ // Prefer the active tab of a session over the first published row.
128+ for i, entry := range entries {
129+ if !entry.defaultCursor {
130+ continue
131+ }
132+ set.cursor = protocol.PickerCursor{Key: entry.line.Key, Index: i}
133+ break
134+ }
135+ }
136+ if set.cursor.Key == "" && len(set.lines) > 0 {
137+ // Last resort: the first row the source authorised as a cursor
138+ // destination. A row kept for inspection or skipped by the source is
139+ // never hinted.
140+ for i, line := range set.lines {
141+ if !line.Focusable {
142+ continue
143+ }
144+ if target, ok := set.keys[line.Key]; ok && !target.Stopped {
145+ set.cursor = protocol.PickerCursor{Key: line.Key, Index: i}
146+ break
147+ }
148+ }
149+ }
150+ return set
151+}
152+
153+// pickerRowKey derives the opaque key of one picker row. Local rows are keyed by
154+// incarnation and name, which stays readable for the common case. A remote row
155+// also carries its owning endpoint: the same machine can be registered under
156+// more than one target (a pinned alias and one learned by attach), and two
157+// endpoints may legitimately expose the same session lifecycle and name, so an
158+// unqualified key would collide and the whole snapshot would be rejected. The
159+// digest keeps the key unique per endpoint while staying inside the bounded key
160+// length.
161+func pickerRowKey(view pickerSessionView, name string) string {
162+ if view.RemoteKey == nil {
163+ return pickerClientKey(view.Incarnation, name)
164+ }
165+ digest := sha256.Sum256([]byte(view.RemoteKey.Host + "\x00" + string(view.Incarnation[:]) + "\x00" + name))
166+ return "remote:" + base64.RawURLEncoding.EncodeToString(digest[:16])
167+}
168+
169+// pickerSessionLines is the sole owner of intent-specific destination
170+// eligibility. The daemon supplies canonical lifecycle/tab snapshots without
171+// prefiltering.
172+func pickerSessionLines(view pickerSessionView, intent protocol.PickerIntent, source, current pickerSourceFilter) []pickerLine {
173+ stopped := view.Stopped
174+ if view.RemoteTarget != nil {
175+ stopped = view.RemoteTarget.Stopped
176+ }
177+ if intent != protocol.PickerIntentNavigation && stopped {
178+ return nil
179+ }
180+ if intent == protocol.PickerIntentMoveTab && pickerSourceMatchesSession(source, view) {
181+ return nil
182+ }
183+ if intent == protocol.PickerIntentMoveTab && len(view.Tabs) == 0 && !view.CannotAcceptMoves {
184+ return nil
185+ }
186+
187+ targetName := view.TargetName
188+ if intent == protocol.PickerIntentNavigation && !stopped && view.ExpectedCreatedAt == nil {
189+ targetName = ""
190+ } else if targetName == "" {
191+ targetName = view.Name
192+ }
193+ expectedCreatedAt, hasExpectedCreatedAt := int64Value(view.ExpectedCreatedAt)
194+ common := picker.Target{
195+ Session: view.ID, Incarnation: view.Incarnation, Name: targetName,
196+ RemoteKey: view.RemoteKey, RemoteTarget: view.RemoteTarget, RemoteHost: view.RemoteHost,
197+ UnavailableReason: view.RemoteReason, TabIndex: -1, Stopped: stopped,
198+ ExpectedCreatedAt: optionalInt64(expectedCreatedAt, hasExpectedCreatedAt),
199+ }
200+ headerLine := pickerLine{
201+ line: protocol.PickerLine{
202+ Key: pickerRowKey(view, common.Name), Kind: pickerLineKindForView(view),
203+ Label: view.Name, Detail: pickerStatusDetailFor(view, stopped), Stopped: stopped,
204+ Status: pickerStatusFor(view, stopped), StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
205+ },
206+ target: common,
207+ }
208+ if view.RemoteKey != nil {
209+ headerLine.line.Label = view.RemoteKey.Name
210+ if !view.HideRemoteOrigin {
211+ display := view.RemoteKey.Display()
212+ headerLine.line.Detail = display[len(view.RemoteKey.Name):]
213+ }
214+ }
215+ header := headerLine
216+ headerSelectable, headerFocusable, headerDim := pickerHeaderEligibility(view, intent, stopped)
217+ header.line.Actions = pickerActionsFor(view, intent, headerSelectable)
218+ header.line.Focusable = headerFocusable
219+ header.line.Dim = headerDim
220+ if headerFocusable && pickerSelectionMatches(header, current, intent) {
221+ header.cursor = true
222+ }
223+ if !headerFocusable {
224+ header.line.Actions = 0
225+ header.target = picker.Target{}
226+ }
227+
228+ lines := []pickerLine{header}
229+ for i, tab := range view.Tabs {
230+ if intent == protocol.PickerIntentMovePane && pickerSourceMatchesTab(source, view, tab) {
231+ continue
232+ }
233+ tabTarget := common
234+ tabTarget.TabID, tabTarget.TabIndex = tab.TabID, i
235+ // Each tab row carries its own structured remote target: a stopped row
236+ // restores the tab it names, a live row binds its stable tab ID.
237+ if resolved, ok := pickerTabRemoteTarget(view, tab, i); ok {
238+ tabTarget.RemoteTarget = &resolved
239+ }
240+ tabLine := pickerLine{
241+ line: protocol.PickerLine{
242+ Key: pickerRowKey(view, common.Name+"#"+string(tab.TabID)),
243+ Kind: protocol.PickerLineTab,
244+ Label: tab.Name, Detail: tab.Detail, Attention: tab.Attention,
245+ Stopped: stopped, StatusDetail: view.RemoteDetail, Ephemeral: pickerEphemeral(view),
246+ },
247+ target: tabTarget,
248+ }
249+ selectable, focusable, dim := pickerTabEligibility(view, intent, tab, i)
250+ tabLine.line.Actions = pickerActionsFor(view, intent, selectable)
251+ tabLine.line.Focusable = focusable
252+ tabLine.line.Dim = dim
253+ if !focusable {
254+ tabLine.line.Actions = 0
255+ tabLine.target = picker.Target{}
256+ }
257+ if focusable && pickerSelectionMatches(tabLine, current, intent) {
258+ tabLine.cursor = true
259+ }
260+ // A fresh navigation starts on the session's active tab, exactly like
261+ // the daemon-owned model did.
262+ if focusable && intent == protocol.PickerIntentNavigation && i == normalizedActiveTab(view) {
263+ tabLine.defaultCursor = true
264+ }
265+ lines = append(lines, tabLine)
266+ }
267+ if intent == protocol.PickerIntentMovePane && len(lines) == 1 && !view.CannotAcceptMoves {
268+ return nil
269+ }
270+ return lines
271+}
272+
273+func pickerHeaderEligibility(view pickerSessionView, intent protocol.PickerIntent, stopped bool) (selectable, focusable, dim bool) {
274+ if intent == protocol.PickerIntentMoveTab {
275+ selectable, focusable = true, true
276+ } else if view.ID != "" && pickerViewHasTarget(view) {
277+ selectable, focusable = false, false
278+ }
279+ if intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerViewHasTarget(view) {
280+ selectable, focusable = true, true
281+ }
282+ if pickerViewIsRemote(view) {
283+ if view.RemoteTarget != nil {
284+ selectable = intent == protocol.PickerIntentNavigation && stopped && len(view.Tabs) == 0 && pickerRemoteActivatable(view) && view.RemoteTarget.Validate() == nil
285+ focusable = intent == protocol.PickerIntentNavigation && len(view.Tabs) == 0
286+ } else {
287+ selectable = false
288+ focusable = intent == protocol.PickerIntentNavigation && view.RemoteHost != ""
289+ }
290+ dim = view.RemoteActivation == pickerRemoteUnavailable
291+ }
292+ if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
293+ selectable, focusable, dim = false, false, true
294+ }
295+ return selectable, focusable, dim
296+}
297+
298+func pickerTabEligibility(view pickerSessionView, intent protocol.PickerIntent, tab pickerTabEntry, index int) (selectable, focusable, dim bool) {
299+ selectable, focusable = intent != protocol.PickerIntentMoveTab, intent != protocol.PickerIntentMoveTab
300+ if pickerViewIsRemote(view) {
301+ if view.RemoteTarget == nil {
302+ focusable, selectable = false, false
303+ } else {
304+ resolved, resolvable := pickerTabRemoteTarget(view, tab, index)
305+ focusable = true
306+ selectable = resolvable && resolved.Validate() == nil && intent == protocol.PickerIntentNavigation && pickerRemoteActivatable(view)
307+ }
308+ dim = view.RemoteActivation == pickerRemoteUnavailable
309+ }
310+ if intent != protocol.PickerIntentNavigation && view.CannotAcceptMoves {
311+ selectable, focusable, dim = false, false, true
312+ }
313+ return selectable, focusable, dim
314+}
315+
316+// pickerTabRemoteTarget resolves the per-tab copy of a remote session's exact
317+// route. A stopped session targets the tab the row names (stable ID when known,
318+// otherwise its ordinal and raw name); a live session binds the tab's stable
319+// ID. The second result is false when no exact target can be expressed.
320+func pickerTabRemoteTarget(view pickerSessionView, tab pickerTabEntry, index int) (domain.RemoteSessionTarget, bool) {
321+ if view.RemoteTarget == nil {
322+ return domain.RemoteSessionTarget{}, false
323+ }
324+ target := *view.RemoteTarget
325+ if target.Stopped {
326+ target.LiveTabID = ""
327+ if tab.TabID != "" {
328+ target.StoppedTab = domain.NewStableTabSelector(tab.TabID)
329+ return target, true
330+ }
331+ if target.StoppedTab == (domain.TabSelector{}) {
332+ return target, true
333+ }
334+ selector, ok := remoteStoppedOrdinalSelector(index, tab.RawName, len(view.Tabs))
335+ if !ok {
336+ return domain.RemoteSessionTarget{}, false
337+ }
338+ target.StoppedTab = selector
339+ return target, true
340+ }
341+ target.StoppedTab = domain.TabSelector{}
342+ target.LiveTabID = tab.TabID
343+ return target, true
344+}
345+
346+// pickerActionsFor maps eligibility to the actions the source authorises. The
347+// daemon revalidates key and lifecycle at commit, so these bits are a
348+// presentation contract, never authority.
349+// normalizedActiveTab clamps a session's active tab index into its tab range.
350+func normalizedActiveTab(view pickerSessionView) int {
351+ if view.Active < 0 || view.Active >= len(view.Tabs) {
352+ return 0
353+ }
354+ return view.Active
355+}
356+
357+func pickerActionsFor(view pickerSessionView, intent protocol.PickerIntent, selectable bool) protocol.PickerLineActions {
358+ if !selectable {
359+ return 0
360+ }
361+ if intent != protocol.PickerIntentNavigation {
362+ return protocol.PickerCanMove
363+ }
364+ actions := protocol.PickerCanNavigate
365+ if !pickerViewIsRemote(view) && pickerViewHasTarget(view) {
366+ actions |= protocol.PickerCanKill
367+ }
368+ return actions
369+}
370+
371+func pickerViewHasTarget(view pickerSessionView) bool {
372+ return view.ID != "" && view.Name != ""
373+}
374+
375+// pickerLineKindForView names one published header row's shape. A remote host
376+// status row carries no session key and no structured route: it is published
377+// for inspection, so it is a host row rather than a session row.
378+func pickerLineKindForView(view pickerSessionView) protocol.PickerLineKind {
379+ if pickerViewHostStatus(view) {
380+ return protocol.PickerLineHost
381+ }
382+ return protocol.PickerLineSession
383+}
384+
385+// pickerViewHostStatus reports a catalogue host line: an endpoint with no
386+// session identity behind it.
387+func pickerViewHostStatus(view pickerSessionView) bool {
388+ return view.RemoteHost != "" && view.RemoteKey == nil && view.RemoteTarget == nil
389+}
390+
391+func pickerViewIsRemote(view pickerSessionView) bool {
392+ return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
393+}
394+
395+func pickerEphemeral(view pickerSessionView) bool {
396+ return view.RemoteKey != nil || view.RemoteTarget != nil || view.RemoteHost != ""
397+}
398+
399+func pickerRemoteActivatable(view pickerSessionView) bool {
400+ return view.RemoteActivation == pickerRemoteAttach || view.RemoteActivation == pickerRemoteRestart
401+}
402+
403+func pickerStatusFor(view pickerSessionView, stopped bool) protocol.PickerLineStatus {
404+ remote := pickerViewIsRemote(view)
405+ if remote {
406+ switch view.RemoteReason {
407+ case domain.RemoteReasonHostUnreachable:
408+ return protocol.PickerLineStatusDown
409+ case domain.RemoteReasonVersionMismatch:
410+ return protocol.PickerLineStatusVersion
411+ case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale:
412+ return protocol.PickerLineStatusStale
413+ case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure:
414+ return protocol.PickerLineStatusError
415+ }
416+ }
417+ if stopped {
418+ return protocol.PickerLineStatusStopped
419+ }
420+ if !remote {
421+ return protocol.PickerLineStatusNone
422+ }
423+ if view.RemoteActivation == pickerRemoteAttach {
424+ return protocol.PickerLineStatusUp
425+ }
426+ return protocol.PickerLineStatusError
427+}
428+
429+// pickerStatusDetailFor fills the muted detail of a header line: a stopped
430+// session reads "stopped" and a remote reason replaces it.
431+func pickerStatusDetailFor(view pickerSessionView, stopped bool) string {
432+ if view.RemoteReason != "" {
433+ return view.RemoteReason
434+ }
435+ if stopped {
436+ return "stopped"
437+ }
438+ return ""
439+}
440+
441+func pickerSelectionMatches(line pickerLine, current pickerSourceFilter, intent protocol.PickerIntent) bool {
442+ target := line.target
443+ if target.Session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && target.Incarnation != current.Incarnation) {
444+ return false
445+ }
446+ if target.RemoteKey != nil && line.line.Kind == protocol.PickerLineSession {
447+ return line.line.Actions != 0 && (current.RemoteKey == nil || *target.RemoteKey == *current.RemoteKey)
448+ }
449+ if intent == protocol.PickerIntentMoveTab {
450+ return line.line.Kind == protocol.PickerLineSession
451+ }
452+ // A stopped session with no retained tab metadata uses its selectable
453+ // header as the default restart target and matches on exact session
454+ // identity.
455+ if current.TabID == "" && target.Stopped {
456+ return line.line.Kind == protocol.PickerLineSession && line.line.Actions != 0
457+ }
458+ if line.line.Kind != protocol.PickerLineTab {
459+ return false
460+ }
461+ return target.TabID == current.TabID
462+}
463+
464+func pickerSourceMatchesSession(source pickerSourceFilter, view pickerSessionView) bool {
465+ if source.Session != view.ID {
466+ return false
467+ }
468+ return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == view.Incarnation
469+}
470+
471+func pickerSourceMatchesTab(source pickerSourceFilter, view pickerSessionView, tab pickerTabEntry) bool {
472+ return pickerSourceMatchesSession(source, view) && source.TabID == tab.TabID
473+}
474+
475+func remoteStoppedOrdinalSelector(index int, rawName string, tabCount int) (domain.TabSelector, bool) {
476+ if tabCount > math.MaxUint16 || index < 0 || index >= tabCount {
477+ return domain.TabSelector{}, false
478+ }
479+ return domain.NewOrdinalTabSelector(uint16(index), rawName, uint16(tabCount)), true
480+}
481+
482+func int64Value(value *int64) (int64, bool) {
483+ if value == nil {
484+ return 0, false
485+ }
486+ return *value, true
487+}
488+
489+func optionalInt64(value int64, present bool) *int64 {
490+ if !present {
491+ return nil
492+ }
493+ copyValue := value
494+ return &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)
38 func formatRecentRouteSnapshot(snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay {38 func formatRecentRouteSnapshot(snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay {
39 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))39 return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot))
40 }40 }
41-
42-// formatRecentRouteSnapshotForAttachment resolves attention against this
43-// daemon's live sessions. The client retains route ordering and presentation;
44-// it subscribes only the exact routes this daemon serves.
45-func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot protocol.RecentRouteSnapshot) []recentRouteDisplay {
46- presentations := recentRoutePresentationsFromSnapshot(snapshot)
47- if d == nil || ac == nil {
48- return formatRecentRoutePresentations(presentations)
49- }
50- for i, entry := range snapshot.Entries {
51- target, ok := ac.routeAttentionTarget(protocol.RouteRef{Key: entry.Key, Generation: entry.Generation})
52- if ok {
53- presentations[i].attention = d.routeHasAttention(target)
54- }
55- }
56- return formatRecentRoutePresentations(presentations)
57-}
58-
59-func (d *Daemon) routeHasAttention(target protocol.ExactSessionTarget) bool {
60- d.mu.Lock()
61- sess := d.findByNameLocked(target.SessionName)
62- d.mu.Unlock()
63- if sess == nil {
64- return false
65- }
66- sess.mu.Lock()
67- defer sess.mu.Unlock()
68- if sess.incarnation != target.LifecycleID {
69- return false
70- }
71- for _, tb := range sess.tabs {
72- if tb.attention {
73- return true
74- }
75- }
76- return false
77-}
M internal/usecase/daemon/recent_route_snapshot_test.go +14 -36
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/protocol"6 "github.com/bnema/vev/internal/protocol"
8 "github.com/bnema/vev/internal/protocol/wire"7 "github.com/bnema/vev/internal/protocol/wire"
9 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
@@ -26,41 +25,20 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
26 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})25 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
27 }26 }
28 27
29-func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {28+func TestAttachmentStatusUsesClientPublishedAttention(t *testing.T) {
30- for _, tt := range []struct {29+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
31- name string30+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
32- targetID domain.SessionLifecycleID31+ Generation: 2,
33- attention bool32+ Active: protocol.RouteRef{Key: 3, Generation: 2},
34- }{33+ Entries: []protocol.RecentRouteEntry{{
35- {name: "matching lifecycle", targetID: domain.SessionLifecycleID{9}, attention: true},34+ Key: 2, Generation: 1, Target: testRouteTarget("local", 9), Name: "local", Kind: protocol.RouteKindLocal, Attention: true,
36- {name: "stale lifecycle", targetID: domain.SessionLifecycleID{8}, attention: false},35+ }},
37- } {36+ })
38- t.Run(tt.name, func(t *testing.T) {37+
39- d, sess, ac, _ := newManualSessionWithPTYs(t, nil)38+ state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
40- sess.mu.Lock()39+
41- sess.incarnation = domain.SessionLifecycleID{9}40+ require.Len(t, state.mru, 1)
42- sess.tabs[0].attention = true41+ require.True(t, state.mru[0].attention)
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- }
64 }42 }
65 43
66 func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {44 func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
M internal/usecase/daemon/remote_attach.go +0 -23
@@ -103,29 +103,6 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
103 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)103 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
104 }104 }
105 105
106-func (d *Daemon) sendNavigationActionForAttachment(effect *attachmentEffect, action protocol.NavigationAction) error {
107- directive := protocol.NavigationDirective{Action: action}
108- armed := false
109- if action == protocol.NavigationOpenHomePicker {
110- leaseID, err := d.armParkedRoute(effect)
111- if err != nil {
112- return err
113- }
114- directive.LeaseID = leaseID
115- armed = true
116- }
117- rollback := func() {
118- if armed {
119- effect.ac.clearParkedRoute()
120- }
121- }
122- if err := effect.sendControl(directive); err != nil {
123- rollback()
124- return err
125- }
126- return nil
127-}
128-
129 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error {106 func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action protocol.RouteNavigationAction) error {
130 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {107 if action.SnapshotGeneration == 0 || action.Key == 0 || action.Generation == 0 {
131 return errAttachmentTransition108 return errAttachmentTransition
M internal/usecase/daemon/remote_attach_test.go +2 -2
@@ -59,7 +59,7 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
59 59
60 require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{60 require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{
61 Name: "stopped", Incarnation: lifecycle,61 Name: "stopped", Incarnation: lifecycle,
62- }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select"))62+ }, sessionHandoffGuard{allowSamePeer: true}, "picker-select"))
63 63
64 cleanup := receiveRemotePicker(t, sends, "graphics cleanup")64 cleanup := receiveRemotePicker(t, sends, "graphics cleanup")
65 require.Equal(t, wire.MsgOutput, cleanup.Type)65 require.Equal(t, wire.MsgOutput, cleanup.Type)
@@ -87,7 +87,7 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
87 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}87 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
88 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}88 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
89 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}89 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
90- require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select"))90+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select"))
91 frame := receiveRemotePicker(t, sends, "rich attach target")91 frame := receiveRemotePicker(t, sends, "rich attach target")
92 got, err := wire.UnmarshalAttachTarget(frame.Payload)92 got, err := wire.UnmarshalAttachTarget(frame.Payload)
93 require.NoError(t, err)93 require.NoError(t, err)
M internal/usecase/daemon/remote_directory_view.go +4 -2
@@ -76,13 +76,15 @@ func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) {
76 if ac == nil || ac.overlays == nil {76 if ac == nil || ac.overlays == nil {
77 return77 return
78 }78 }
79- pickerOpen := ac.overlays.pickerActive()79+ pickerOpen := ac.overlays.pickerClientActive()
80 paletteOpen := ac.overlays.paletteActive()80 paletteOpen := ac.overlays.paletteActive()
81 if !pickerOpen && !paletteOpen {81 if !pickerOpen && !paletteOpen {
82 return82 return
83 }83 }
84+ // The picker and the palette both read the directory: republish the
85+ // interaction snapshot with a new revision when the row set changed.
84 if pickerOpen {86 if pickerOpen {
85- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})87+ d.refreshPickerSnapshot(ac)
86 }88 }
87 if paletteOpen {89 if paletteOpen {
88 d.refreshPalette(ac)90 d.refreshPalette(ac)
M internal/usecase/daemon/remote_picker.go +27 -24
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol/catalogue"11 "github.com/bnema/vev/internal/protocol/catalogue"
12- "github.com/bnema/vev/internal/usecase/picker"
13 )12 )
14 13
15 // directoryInventoryStaleAge is the presentation freshness threshold: past14 // directoryInventoryStaleAge is the presentation freshness threshold: past
@@ -64,12 +63,12 @@ func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool
64 return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))63 return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))
65 }64 }
66 65
67-func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) picker.SessionView {66+func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
68 key, target := remoteCatalogSessionTarget(key, session)67 key, target := remoteCatalogSessionTarget(key, session)
69 stopped := remoteSessionStateStopped(session.State)68 stopped := remoteSessionStateStopped(session.State)
70 broken := session.State == catalogue.RemoteCatalogSessionBroken69 broken := session.State == catalogue.RemoteCatalogSessionBroken
71 tabs := catalogue.CatalogTabs(session)70 tabs := catalogue.CatalogTabs(session)
72- viewTabs := make([]picker.TabEntry, 0, len(tabs))71+ viewTabs := make([]pickerTabEntry, 0, len(tabs))
73 active := 072 active := 0
74 for i, tab := range tabs {73 for i, tab := range tabs {
75 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {74 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
@@ -79,7 +78,7 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
79 if name == "" {78 if name == "" {
80 name = fmt.Sprintf("%d", int(tab.Index)+1)79 name = fmt.Sprintf("%d", int(tab.Index)+1)
81 }80 }
82- viewTabs = append(viewTabs, picker.TabEntry{81+ viewTabs = append(viewTabs, pickerTabEntry{
83 TabID: domain.TabStableID(tab.ID),82 TabID: domain.TabStableID(tab.ID),
84 Name: name,83 Name: name,
85 RawName: tab.Name,84 RawName: tab.Name,
@@ -94,21 +93,25 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
94 remoteTarget := &target93 remoteTarget := &target
95 reason := directorySessionReason(host, session, target)94 reason := directorySessionReason(host, session, target)
96 targetValid := remoteTarget.Validate() == nil95 targetValid := remoteTarget.Validate() == nil
97- activation := picker.RemoteUnavailable96+ activation := pickerRemoteUnavailable
98 if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {97 if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {
99 // Known inventory is attemptable at any age: freshness is98 // Known inventory is attemptable at any age: freshness is
100 // presentation information, not attach authority. Exact session,99 // presentation information, not attach authority. Exact session,
101 // lifecycle and tab validation still applies at submit, and the100 // lifecycle and tab validation still applies at submit, and the
102 // destination rejects precisely.101 // destination rejects precisely.
103 if stopped {102 if stopped {
104- activation = picker.RemoteRestart103+ activation = pickerRemoteRestart
105 } else {104 } else {
106- activation = picker.RemoteAttach105+ activation = pickerRemoteAttach
107 }106 }
108 }107 }
109 108
110- return picker.SessionView{109+ return pickerSessionView{
111- ID: key.ID(),110+ ID: key.ID(),
111+ // A remote row's incarnation is its session lifecycle. Leaving it zero
112+ // would make every live session on one host share one row identity, so
113+ // two of them would collide and the whole snapshot would be rejected.
114+ Incarnation: session.LifecycleID,
112 Name: key.Display(),115 Name: key.Display(),
113 RemoteKey: &key,116 RemoteKey: &key,
114 RemoteTarget: remoteTarget,117 RemoteTarget: remoteTarget,
@@ -153,26 +156,26 @@ func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.Rem
153 }156 }
154 }157 }
155 158
156-func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) picker.RemoteAvailability {159+func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) pickerRemoteAvailability {
157 switch host.Availability {160 switch host.Availability {
158 case domain.RemoteAvailabilityReachable:161 case domain.RemoteAvailabilityReachable:
159 if directoryInventoryStale(host, now) {162 if directoryInventoryStale(host, now) {
160- return picker.RemoteStale163+ return pickerRemoteStale
161 }164 }
162- return picker.RemoteFresh165+ return pickerRemoteFresh
163 case domain.RemoteAvailabilityIncompatible:166 case domain.RemoteAvailabilityIncompatible:
164- return picker.RemoteVersionMismatch167+ return pickerRemoteVersionMismatch
165 case domain.RemoteAvailabilityUnknown:168 case domain.RemoteAvailabilityUnknown:
166- return picker.RemoteCached169+ return pickerRemoteCached
167 default:170 default:
168 if host.Checking {171 if host.Checking {
169- return picker.RemoteCached172+ return pickerRemoteCached
170 }173 }
171- return picker.RemoteStale174+ return pickerRemoteStale
172 }175 }
173 }176 }
174 177
175-func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation picker.RemoteActivation, now time.Time) string {178+func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation pickerRemoteActivation, now time.Time) string {
176 if host.Checking {179 if host.Checking {
177 return "checking remote…"180 return "checking remote…"
178 }181 }
@@ -202,7 +205,7 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo
202 return "session broken"205 return "session broken"
203 case reason == domain.RemoteReasonIdentityChanged:206 case reason == domain.RemoteReasonIdentityChanged:
204 return "identity changed"207 return "identity changed"
205- case activation == picker.RemoteRestart:208+ case activation == pickerRemoteRestart:
206 return "stopped — Enter to restart"209 return "stopped — Enter to restart"
207 case session.State == catalogue.RemoteCatalogSessionUp:210 case session.State == catalogue.RemoteCatalogSessionUp:
208 return "up"211 return "up"
@@ -217,24 +220,24 @@ func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.Remo
217 // while the remote directory has never been initialized: it distinguishes220 // while the remote directory has never been initialized: it distinguishes
218 // "still checking remotes" from "no remotes configured". Enter on the221 // "still checking remotes" from "no remotes configured". Enter on the
219 // row fails safe through the usual unavailable-target path.222 // row fails safe through the usual unavailable-target path.
220-func remotePickerCheckingView() picker.SessionView {223+func remotePickerCheckingView() pickerSessionView {
221- return picker.SessionView{224+ return pickerSessionView{
222 ID: domain.SessionID("remote:checking"),225 ID: domain.SessionID("remote:checking"),
223 Name: "checking remotes…",226 Name: "checking remotes…",
224- RemoteActivation: picker.RemoteUnavailable,227+ RemoteActivation: pickerRemoteUnavailable,
225 CannotAcceptMoves: true,228 CannotAcceptMoves: true,
226 }229 }
227 }230 }
228 231
229-func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) picker.SessionView {232+func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) pickerSessionView {
230- return picker.SessionView{233+ return pickerSessionView{
231 ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),234 ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),
232 Name: host.Endpoint,235 Name: host.Endpoint,
233 RemoteHost: host.Endpoint,236 RemoteHost: host.Endpoint,
234 RemoteReason: directoryHostReason(host),237 RemoteReason: directoryHostReason(host),
235 RemoteAvailability: directoryPickerAvailability(host, now),238 RemoteAvailability: directoryPickerAvailability(host, now),
236 RemoteDetail: directoryHostDetail(host),239 RemoteDetail: directoryHostDetail(host),
237- RemoteActivation: picker.RemoteUnavailable,240+ RemoteActivation: pickerRemoteUnavailable,
238 CannotAcceptMoves: true,241 CannotAcceptMoves: true,
239 }242 }
240 }243 }
M internal/usecase/daemon/remote_picker_test.go +227 -275
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9 "math"9 "math"
10+ "strings"
10 "sync"11 "sync"
11 "testing"12 "testing"
12 "time"13 "time"
@@ -107,7 +108,7 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
107 require.True(t, view.Stopped)108 require.True(t, view.Stopped)
108 require.NotNil(t, view.RemoteTarget)109 require.NotNil(t, view.RemoteTarget)
109 require.True(t, view.RemoteTarget.Stopped)110 require.True(t, view.RemoteTarget.Stopped)
110- require.Equal(t, picker.RemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")111+ require.Equal(t, pickerRemoteRestart, view.RemoteActivation, "structured stopped rows have an explicit safe restore target")
111 require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)112 require.Equal(t, "stopped — Enter to restart", view.RemoteDetail)
112 113
113 }114 }
@@ -179,8 +180,8 @@ func TestRemoteCatalogTargetReadyIgnoresAge(t *testing.T) {
179 180
180 views, _ := d.pickerViews(nil, nil)181 views, _ := d.pickerViews(nil, nil)
181 require.Len(t, views, 1)182 require.Len(t, views, 1)
182- require.Equal(t, picker.RemoteAttach, views[0].RemoteActivation)183+ require.Equal(t, pickerRemoteAttach, views[0].RemoteActivation)
183- require.Equal(t, picker.RemoteStale, views[0].RemoteAvailability)184+ require.Equal(t, pickerRemoteStale, views[0].RemoteAvailability)
184 }185 }
185 186
186 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {187 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
@@ -241,8 +242,8 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
241 require.NotNil(t, views[0].RemoteTarget)242 require.NotNil(t, views[0].RemoteTarget)
242 require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)243 require.Equal(t, test.wantTabID, views[0].RemoteTarget.LiveTabID)
243 244
244- model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})245+ set := pickerLineSetFor(views, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
245- target, ok := model.Selected()246+ target, ok := firstSelectableTarget(t, set)
246 require.True(t, ok)247 require.True(t, ok)
247 require.NotNil(t, target.RemoteTarget)248 require.NotNil(t, target.RemoteTarget)
248 require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)249 require.Equal(t, test.wantTabID, target.RemoteTarget.LiveTabID)
@@ -258,7 +259,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
258 tests := []struct {259 tests := []struct {
259 name string260 name string
260 host ports.RemoteHostSnapshot261 host ports.RemoteHostSnapshot
261- want picker.RemoteAvailability262+ want pickerRemoteAvailability
262 wantActivate bool263 wantActivate bool
263 }{264 }{
264 {265 {
@@ -267,7 +268,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
267 Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown,268 Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown,
268 Checking: true, InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},269 Checking: true, InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
269 },270 },
270- want: picker.RemoteCached, wantActivate: true,271+ want: pickerRemoteCached, wantActivate: true,
271 },272 },
272 {273 {
273 name: "unreachable with inventory renders stale and stays attemptable",274 name: "unreachable with inventory renders stale and stays attemptable",
@@ -276,7 +277,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
276 LastSuccess: time.Unix(10, 0), InventoryKnown: true,277 LastSuccess: time.Unix(10, 0), InventoryKnown: true,
277 Sessions: []catalogue.RemoteCatalogSession{session},278 Sessions: []catalogue.RemoteCatalogSession{session},
278 },279 },
279- want: picker.RemoteStale, wantActivate: true,280+ want: pickerRemoteStale, wantActivate: true,
280 },281 },
281 {282 {
282 name: "version mismatch gates activation",283 name: "version mismatch gates activation",
@@ -284,7 +285,7 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
284 Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible,285 Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible,
285 InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},286 InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
286 },287 },
287- want: picker.RemoteVersionMismatch, wantActivate: false,288+ want: pickerRemoteVersionMismatch, wantActivate: false,
288 },289 },
289 }290 }
290 291
@@ -298,9 +299,9 @@ func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
298 require.Len(t, views, 1)299 require.Len(t, views, 1)
299 require.Equal(t, test.want, views[0].RemoteAvailability)300 require.Equal(t, test.want, views[0].RemoteAvailability)
300 if test.wantActivate {301 if test.wantActivate {
301- require.NotEqual(t, picker.RemoteUnavailable, views[0].RemoteActivation)302+ require.NotEqual(t, pickerRemoteUnavailable, views[0].RemoteActivation)
302 } else {303 } else {
303- require.Equal(t, picker.RemoteUnavailable, views[0].RemoteActivation)304+ require.Equal(t, pickerRemoteUnavailable, views[0].RemoteActivation)
304 }305 }
305 })306 })
306 }307 }
@@ -326,19 +327,19 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) {
326 for _, test := range []struct {327 for _, test := range []struct {
327 name string328 name string
328 host ports.RemoteHostSnapshot329 host ports.RemoteHostSnapshot
329- want picker.RemoteAvailability330+ want pickerRemoteAvailability
330 wantDetail string331 wantDetail string
331 }{332 }{
332 {333 {
333 name: "unreachable",334 name: "unreachable",
334 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable},335 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable},
335- want: picker.RemoteStale,336+ want: pickerRemoteStale,
336 wantDetail: "unreachable",337 wantDetail: "unreachable",
337 },338 },
338 {339 {
339 name: "version mismatch",340 name: "version mismatch",
340 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible},341 host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible},
341- want: picker.RemoteVersionMismatch,342+ want: pickerRemoteVersionMismatch,
342 wantDetail: "version mismatch",343 wantDetail: "version mismatch",
343 },344 },
344 } {345 } {
@@ -352,7 +353,7 @@ func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) {
352 require.Equal(t, "arch", views[0].Name)353 require.Equal(t, "arch", views[0].Name)
353 require.Equal(t, test.want, views[0].RemoteAvailability)354 require.Equal(t, test.want, views[0].RemoteAvailability)
354 require.Equal(t, test.wantDetail, views[0].RemoteDetail)355 require.Equal(t, test.wantDetail, views[0].RemoteDetail)
355- model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})356+ model := picker.New(pickerLineSetFor(views, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{}).lines, picker.Config{Intent: protocol.PickerIntentNavigation})
356 _, selectable := model.Selected()357 _, selectable := model.Selected()
357 require.False(t, selectable)358 require.False(t, selectable)
358 })359 })
@@ -387,7 +388,7 @@ func TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable(t *testing.T) {
387 domain.RemoteSessionKey{Host: "arch", Name: "work"},388 domain.RemoteSessionKey{Host: "arch", Name: "work"},
388 session, test.host, now,389 session, test.host, now,
389 )390 )
390- require.Equal(t, picker.RemoteAttach, view.RemoteActivation)391+ require.Equal(t, pickerRemoteAttach, view.RemoteActivation)
391 require.NotNil(t, view.RemoteTarget)392 require.NotNil(t, view.RemoteTarget)
392 require.NoError(t, view.RemoteTarget.Validate())393 require.NoError(t, view.RemoteTarget.Validate())
393 })394 })
@@ -404,11 +405,13 @@ func TestRemotePickerOverlayCausesNoDirectoryIO(t *testing.T) {
404 }405 }
405 d.remoteDirectory = directory406 d.remoteDirectory = directory
406 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")407 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
407-408+ effect := admitPickerEffectForTest(t, sess, ac)
408- model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})409+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
409- d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})410+ d.refreshPickerSnapshot(ac)
410- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})411+ ac.overlays.pickerMu.Lock()
411- d.closePicker(ac)412+ interaction := ac.overlays.pickerInteraction
413+ ac.overlays.pickerMu.Unlock()
414+ require.True(t, d.closePickerForAttachment(ac, effect, interaction))
412 415
413 require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation")416 require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation")
414 require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots")417 require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots")
@@ -480,6 +483,84 @@ func seedPickerDirectoryHost(t *testing.T, d *Daemon, endpoint string, fetchedAt
480 seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...))483 seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...))
481 }484 }
482 485
486+func TestRemotePickerNavigationCommitsARemoteRowThroughTheHandoff(t *testing.T) {
487+ // A committed remote row names an endpoint-qualified session, never a local
488+ // one. The selection gate must not resolve it as a local lifecycle, and the
489+ // handoff must be what reaches the client.
490+ lifecycle := remoteLifecycleForTest()
491+ remoteSession := catalogue.RemoteCatalogSession{
492+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
493+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "main", Index: 0}}, ActiveTabID: "tab-1",
494+ }
495+ d := newRemotePickerDaemon()
496+ seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession)
497+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
498+ token := sess.captureAttachmentCapability(ac, ac.transport())
499+ effect, admitted := ac.beginAttachmentEffect(token)
500+ require.True(t, admitted)
501+ defer effect.End()
502+
503+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
504+ _ = awaitPickerOffer(t, sends)
505+ snapshot := awaitPickerSnapshot(t, sends)
506+ key := ""
507+ for _, line := range snapshot.Lines {
508+ if line.Actions&protocol.PickerCanNavigate != 0 && strings.HasPrefix(line.Key, "remote:") {
509+ key = line.Key
510+ break
511+ }
512+ }
513+ require.NotEmpty(t, key, "the remote session's rows must authorise navigation")
514+
515+ d.resolvePickerSelection(effect, navigateSelection(snapshot, key))
516+
517+ frame := awaitFrame(t, sends, wire.MsgAttachTarget)
518+ handoff, err := wire.UnmarshalAttachTarget(frame.Payload)
519+ require.NoError(t, err)
520+ require.Equal(t, "arch", handoff.Endpoint)
521+ require.Equal(t, "work", handoff.Session)
522+}
523+
524+func TestRemotePickerNavigationRejectsARowTheCatalogNoLongerHolds(t *testing.T) {
525+ // The handoff is the remote target's lifecycle gate, so a committed row
526+ // whose session was replaced must fail precisely instead of attaching to a
527+ // route the catalogue no longer publishes.
528+ lifecycle := remoteLifecycleForTest()
529+ remoteSession := catalogue.RemoteCatalogSession{
530+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
531+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "main", Index: 0}}, ActiveTabID: "tab-1",
532+ }
533+ d := newRemotePickerDaemon()
534+ seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession)
535+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
536+ token := sess.captureAttachmentCapability(ac, ac.transport())
537+ effect, admitted := ac.beginAttachmentEffect(token)
538+ require.True(t, admitted)
539+ defer effect.End()
540+
541+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
542+ _ = awaitPickerOffer(t, sends)
543+ snapshot := awaitPickerSnapshot(t, sends)
544+ key := ""
545+ for _, line := range snapshot.Lines {
546+ if line.Actions&protocol.PickerCanNavigate != 0 && strings.HasPrefix(line.Key, "remote:") {
547+ key = line.Key
548+ break
549+ }
550+ }
551+ require.NotEmpty(t, key)
552+
553+ // The same session name now carries a different lifecycle.
554+ replaced := remoteSession
555+ replaced.LifecycleID = domain.SessionLifecycleID{9: 3}
556+ seedPickerDirectoryHost(t, d, "arch", time.Unix(20, 0), replaced)
557+
558+ d.resolvePickerSelection(effect, navigateSelection(snapshot, key))
559+
560+ failure := awaitPickerFailure(t, sends)
561+ require.Equal(t, protocol.PickerNavigationFailed, failure.Code)
562+}
563+
483 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {564 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
484 lifecycle := remoteLifecycleForTest()565 lifecycle := remoteLifecycleForTest()
485 remoteSession := catalogue.RemoteCatalogSession{566 remoteSession := catalogue.RemoteCatalogSession{
@@ -495,7 +576,7 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
495 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}576 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
496 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}577 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
497 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}578 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
498- require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select"))579+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select"))
499 580
500 frame := receiveRemotePicker(t, sends, "attach target")581 frame := receiveRemotePicker(t, sends, "attach target")
501 require.Equal(t, wire.MsgAttachTarget, frame.Type)582 require.Equal(t, wire.MsgAttachTarget, frame.Type)
@@ -519,43 +600,38 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
519 {ID: "tab-b", Name: "beta", Index: 1},600 {ID: "tab-b", Name: "beta", Index: 1},
520 },601 },
521 }602 }
522- key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}603+ // The stopping tab is offered by the daemon's published keys: a stopped
523- host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)604+ // remote session keeps a structured restore target per tab.
524- remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))605+ local := newRemotePickerDaemon()
525- model := picker.New([]picker.SessionView{606+ seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession)
526- {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},607+ localSession, localAttachment, _ := addRemoteRefreshPickerOwner(t, local, "local")
527- remoteView,608+ effect := admitPickerEffectForTest(t, localSession, localAttachment)
528- }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})609+ require.NoError(t, local.openPickerForAttachment(localAttachment, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
529- var selected picker.Target610+ localAttachment.overlays.pickerMu.Lock()
530- for range 4 {611+ selected, ok := selectableTargetMatching(t, pickerLineSet{keys: localAttachment.overlays.pickerKeys}, func(target picker.Target) bool {
531- candidate, ok := model.Selected()612+ return target.RemoteTarget != nil && target.RemoteTarget.StoppedTab.StableID == "tab-b"
532- if ok && candidate.RemoteTarget != nil && candidate.RemoteTarget.StoppedTab.StableID == "tab-b" {613+ })
533- selected = candidate614+ localAttachment.overlays.pickerMu.Unlock()
534- break615+ if !ok {
616+ localAttachment.overlays.pickerMu.Lock()
617+ for candidate, target := range localAttachment.overlays.pickerKeys {
618+ if target.RemoteTarget != nil {
619+ t.Logf("key=%q session=%q remote=%+v", candidate, target.Session, *target.RemoteTarget)
620+ } else {
621+ t.Logf("key=%q session=%q remote=nil", candidate, target.Session)
622+ }
535 }623 }
536- model.Down()624+ localAttachment.overlays.pickerMu.Unlock()
537 }625 }
626+ require.True(t, ok)
538 require.NotNil(t, selected.RemoteTarget)627 require.NotNil(t, selected.RemoteTarget)
539 require.True(t, selected.RemoteTarget.Stopped)628 require.True(t, selected.RemoteTarget.Stopped)
540 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)629 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
630+ require.Equal(t, selected.RemoteKey.ID(), selected.Session, "the row identity must match its remote key")
631+ require.True(t, local.remoteCatalogTargetReady(*selected.RemoteTarget), "the published stopped target must be ready to attach")
541 632
542- local := newRemotePickerDaemon()633+ // The handoff itself is covered by TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession;
543- seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession)634+ // this suite pins the per-tab restore target the stopped rows publish.
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-
559 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})635 remote := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
560 remote.mu.Lock()636 remote.mu.Lock()
561 remote.inactive["work"] = inactiveSession{637 remote.inactive["work"] = inactiveSession{
@@ -566,8 +642,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
566 remote.mu.Unlock()642 remote.mu.Unlock()
567 transport, _ := newCapturingTransport(t)643 transport, _ := newCapturingTransport(t)
568 restored, attachment, err := remote.routeWithContext(context.Background(), protocol.Hello{644 restored, attachment, err := remote.routeWithContext(context.Background(), protocol.Hello{
569- Version: protocol.Version, Intent: protocol.IntentAttach, Name: handoff.Session,645+ Version: protocol.Version, Intent: protocol.IntentAttach, Name: selected.RemoteTarget.SessionName,
570- Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: handoff.RemoteTarget,646+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: selected.RemoteTarget,
571 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,647 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
572 }, transport)648 }, transport)
573 require.NoError(t, err)649 require.NoError(t, err)
@@ -587,21 +663,16 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
587 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}663 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
588 host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)664 host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)
589 remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))665 remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))
590- require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation)666+ require.Equal(t, pickerRemoteRestart, remoteView.RemoteActivation)
591 667
592- model := picker.New([]picker.SessionView{668+ set := pickerLineSetFor([]pickerSessionView{
593- {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},669+ {ID: "local", Name: "local", Tabs: []pickerTabEntry{{TabID: "local-tab", Name: "local"}}},
594 remoteView,670 remoteView,
595- }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})671+ }, protocol.PickerIntentNavigation, pickerSourceFilter{}, pickerSourceFilter{})
596- var selected picker.Target672+ selected, ok := selectableTargetMatching(t, set, func(target picker.Target) bool {
597- for range 3 {673+ return target.RemoteTarget != nil
598- model.Down()674+ })
599- candidate, ok := model.Selected()675+ require.True(t, ok)
600- if ok && candidate.RemoteTarget != nil {
601- selected = candidate
602- break
603- }
604- }
605 require.NotNil(t, selected.RemoteTarget)676 require.NotNil(t, selected.RemoteTarget)
606 require.True(t, selected.RemoteTarget.Stopped)677 require.True(t, selected.RemoteTarget.Stopped)
607 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)678 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
@@ -612,7 +683,7 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
612 token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())683 token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
613 effect, admitted := localAttachment.beginAttachmentEffect(token)684 effect, admitted := localAttachment.beginAttachmentEffect(token)
614 require.True(t, admitted)685 require.True(t, admitted)
615- require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))686+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{}, "picker-select"))
616 687
617 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")688 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")
618 handoff, err := wire.UnmarshalAttachTarget(frame.Payload)689 handoff, err := wire.UnmarshalAttachTarget(frame.Payload)
@@ -636,41 +707,6 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
636 require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID))707 require.Equal(t, restored.tabs[0].stableID, string(attachment.viewSnapshot().tabID))
637 }708 }
638 709
639-func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
640- tests := []struct {
641- name string
642- action protocol.NavigationAction
643- }{
644- {name: "home picker", action: protocol.NavigationOpenHomePicker},
645- {name: "back", action: protocol.NavigationBack},
646- }
647-
648- for _, tt := range tests {
649- t.Run(tt.name, func(t *testing.T) {
650- d := newRemotePickerDaemon()
651- sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
652- if tt.action == protocol.NavigationOpenHomePicker {
653- ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
654- }
655- token := sess.captureAttachmentCapability(ac, ac.transport())
656- effect, admitted := ac.beginAttachmentEffect(token)
657- require.True(t, admitted)
658- defer effect.End()
659- require.NoError(t, d.sendNavigationActionForAttachment(effect, tt.action))
660- frame := receiveRemotePicker(t, sends, "navigation action")
661- require.Equal(t, wire.MsgNavigationAction, frame.Type)
662- directive, err := wire.UnmarshalNavigationDirective(frame.Payload)
663- require.NoError(t, err)
664- require.Equal(t, tt.action, directive.Action)
665- if tt.action == protocol.NavigationOpenHomePicker {
666- require.False(t, directive.LeaseID.IsZero())
667- } else {
668- require.True(t, directive.LeaseID.IsZero())
669- }
670- })
671- }
672-}
673-
674 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {710 func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
675 lifecycle := remoteLifecycleForTest()711 lifecycle := remoteLifecycleForTest()
676 remoteSession := catalogue.RemoteCatalogSession{712 remoteSession := catalogue.RemoteCatalogSession{
@@ -683,26 +719,23 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
683 tr := portsmocks.NewMockServerConnection(t)719 tr := portsmocks.NewMockServerConnection(t)
684 tr.EXPECT().SendServer(mock.Anything).Return(cause)720 tr.EXPECT().SendServer(mock.Anything).Return(cause)
685 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local", tr)721 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local", tr)
686- ac.overlays.pickerMu.Lock()722+ openPickerStateForTest(ac)
687- ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
688- ac.overlays.pickerGeneration++
689- ac.overlays.pickerMu.Unlock()
690 723
691 gone := make(chan struct{})724 gone := make(chan struct{})
692 d.afterClientGoneDetach = func() { close(gone) }725 d.afterClientGoneDetach = func() { close(gone) }
693 token := sess.captureAttachmentCapability(ac, tr)726 token := sess.captureAttachmentCapability(ac, tr)
694- effect, admitted := ac.beginAttachmentEffect(token)727+ sendEffect, admitted := ac.beginAttachmentEffect(token)
695 require.True(t, admitted)728 require.True(t, admitted)
696- defer effect.End()729+ defer sendEffect.End()
697 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}730 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
698 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}731 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
699 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}732 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
700- err := d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select")733+ err := d.sendRemoteAttachTargetForAttachment(sendEffect, target, sessionHandoffGuard{}, "picker-select")
701 var userErr *domain.UserError734 var userErr *domain.UserError
702 require.ErrorAs(t, err, &userErr)735 require.ErrorAs(t, err, &userErr)
703 require.Equal(t, "couldn't attach to remote session", userErr.Msg)736 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
704 require.ErrorIs(t, err, cause)737 require.ErrorIs(t, err, cause)
705- require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")738+ require.True(t, ac.overlays.pickerClientActive(), "failed control send must leave the picker open")
706 select {739 select {
707 case <-gone:740 case <-gone:
708 t.Fatal("failed control send reached clientGoneForAttachment")741 t.Fatal("failed control send reached clientGoneForAttachment")
@@ -711,6 +744,81 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
711 require.Same(t, sess, ac.currentAttachmentSession())744 require.Same(t, sess, ac.currentAttachmentSession())
712 }745 }
713 746
747+// selectableTargetMatching returns the resolved target of the first
748+// selectable line whose target satisfies the predicate. A key-only line set
749+// (every published key is selectable) is accepted too.
750+func selectableTargetMatching(t *testing.T, set pickerLineSet, match func(picker.Target) bool) (picker.Target, bool) {
751+ t.Helper()
752+ if len(set.lines) == 0 {
753+ for _, target := range set.keys {
754+ if match(target) {
755+ return target, true
756+ }
757+ }
758+ return picker.Target{}, false
759+ }
760+ for _, line := range set.lines {
761+ if line.Actions == 0 {
762+ continue
763+ }
764+ target, ok := set.keys[line.Key]
765+ if !ok {
766+ continue
767+ }
768+ if match(target) {
769+ return target, true
770+ }
771+ }
772+ return picker.Target{}, false
773+}
774+
775+// openPickerStateForTest marks the attachment as owning an interaction without
776+// publishing frames. Suites that observe teardown or send failures on the wire
777+// must not hold a live effect while they run.
778+func openPickerStateForTest(ac *attachedClient) uint64 {
779+ ac.overlays.pickerMu.Lock()
780+ defer ac.overlays.pickerMu.Unlock()
781+ ac.overlays.pickerInteraction++
782+ if ac.overlays.pickerInteraction == 0 {
783+ ac.overlays.pickerInteraction = 1
784+ }
785+ ac.overlays.pickerOpen = true
786+ ac.overlays.pickerRevisions = map[string]uint64{servingPickerSourceID: 1}
787+ return ac.overlays.pickerInteraction
788+}
789+
790+// admitPickerEffectForTest admits one effect for the attachment, so typed
791+// picker opens and closes travel the same path a client frame would.
792+func admitPickerEffectForTest(t *testing.T, sess *session, ac *attachedClient) *attachmentEffect {
793+ t.Helper()
794+ effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
795+ require.True(t, admitted)
796+ t.Cleanup(effect.End)
797+ return effect
798+}
799+
800+// firstSelectableTarget returns the resolved target of the first line a source
801+// authorised for navigation.
802+func firstSelectableTarget(t *testing.T, set pickerLineSet) (picker.Target, bool) {
803+ t.Helper()
804+ // The presented selection wins: the daemon's default cursor is a
805+ // session's active tab, so it is the row a fresh interaction would commit.
806+ if set.cursor.Key != "" {
807+ if target, ok := set.keys[set.cursor.Key]; ok {
808+ return target, true
809+ }
810+ }
811+ for _, line := range set.lines {
812+ if line.Actions&protocol.PickerCanNavigate == 0 {
813+ continue
814+ }
815+ target, ok := set.keys[line.Key]
816+ require.True(t, ok, "selectable line %q has no resolved target", line.Key)
817+ return target, true
818+ }
819+ return picker.Target{}, false
820+}
821+
714 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...appports.ServerConnection) (*session, *attachedClient, chan wire.Frame) {822 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, transports ...appports.ServerConnection) (*session, *attachedClient, chan wire.Frame) {
715 t.Helper()823 t.Helper()
716 var tr appports.ServerConnection824 var tr appports.ServerConnection
@@ -747,24 +855,20 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
747 d := newRemotePickerDaemon()855 d := newRemotePickerDaemon()
748 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")856 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
749 ac.resumeCapable = test.resumeCapable857 ac.resumeCapable = test.resumeCapable
750- model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})858+ _ = openPickerStateForTest(ac)
751- d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
752- ac.overlays.pickerMu.Lock()
753- generation := ac.overlays.pickerGeneration
754- ac.overlays.pickerMu.Unlock()
755 859
756 test.teardown(d, sess, ac)860 test.teardown(d, sess, ac)
757 861
758 ac.overlays.pickerMu.Lock()862 ac.overlays.pickerMu.Lock()
759- current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration863+ open, currentInteraction := ac.overlays.pickerOpen, ac.overlays.pickerInteraction
760 ac.overlays.pickerMu.Unlock()864 ac.overlays.pickerMu.Unlock()
761 if !test.resumeCapable {865 if !test.resumeCapable {
762- require.Nil(t, current)866+ require.False(t, open)
763 return867 return
764 }868 }
765 require.True(t, ac.parked)869 require.True(t, ac.parked)
766- require.Same(t, model, current)870+ require.False(t, open, "parking retires picker state before resume")
767- require.Equal(t, generation, currentGeneration)871+ require.NotZero(t, currentInteraction, "interaction IDs remain monotone after retirement")
768 d.mu.Lock()872 d.mu.Lock()
769 parked := d.parked[ac.resumeToken]873 parked := d.parked[ac.resumeToken]
770 d.mu.Unlock()874 d.mu.Unlock()
@@ -785,10 +889,10 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) {
785 d.remoteDirectory = &stubRemoteDirectory{}889 d.remoteDirectory = &stubRemoteDirectory{}
786 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")890 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
787 891
788- findChecking := func(views []picker.SessionView) bool {892+ findChecking := func(views []pickerSessionView) bool {
789 for _, view := range views {893 for _, view := range views {
790 if view.ID == domain.SessionID("remote:checking") {894 if view.ID == domain.SessionID("remote:checking") {
791- if view.RemoteActivation != picker.RemoteUnavailable || !view.CannotAcceptMoves {895+ if view.RemoteActivation != pickerRemoteUnavailable || !view.CannotAcceptMoves {
792 t.Fatal("checking row must be non-actionable")896 t.Fatal("checking row must be non-actionable")
793 }897 }
794 return true898 return true
@@ -804,155 +908,3 @@ func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) {
804 views, _ = d.pickerViews(sess, ac)908 views, _ = d.pickerViews(sess, ac)
805 require.False(t, findChecking(views), "initialized directory must retire the checking row")909 require.False(t, findChecking(views), "initialized directory must retire the checking row")
806 }910 }
807-
808-// TestRemoteDirectoryRefreshPreservesBlockedPreview proves an unchanged
809-// directory revision does not interrupt an in-flight remote preview: the
810-// rebuild keeps the selected exact target, so the preview cancel func is
811-// never invoked and its content survives. A changed selection still
812-// re-registers (and cancels) the preview.
813-func TestRemoteDirectoryRefreshPreservesBlockedPreview(t *testing.T) {
814- d := newRemotePickerDaemon()
815- session := catalogue.RemoteCatalogSession{
816- LifecycleID: remoteLifecycleForTest(), Name: "work", State: catalogue.RemoteCatalogSessionUp,
817- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, ActiveTabID: "tab-1",
818- }
819- seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), session))
820- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
821- d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
822- // Move the cursor onto the remote row.
823- ac.overlays.pickerMu.Lock()
824- for range 8 {
825- target, ok := ac.overlays.picker.Selected()
826- if ok && target.RemoteTarget != nil {
827- break
828- }
829- ac.overlays.picker.Down()
830- }
831- target, ok := ac.overlays.picker.Selected()
832- ac.overlays.pickerMu.Unlock()
833- require.True(t, ok && target.RemoteTarget != nil, "picker must offer the seeded remote row")
834-
835- // Simulate a blocked remote preview fetch.
836- cancelled := make(chan struct{})
837- _, cancel := context.WithCancel(context.Background())
838- defer cancel()
839- ac.overlays.pickerMu.Lock()
840- ac.overlays.pickerRemotePreviewCancel = func() {
841- close(cancelled)
842- cancel()
843- }
844- ac.overlays.pickerRemotePreview = picker.Preview{Width: 8, Height: 4}
845- ac.overlays.pickerMu.Unlock()
846-
847- // An identical directory revision rebuilds rows around the same
848- // exact target: the preview survives untouched.
849- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
850- select {
851- case <-cancelled:
852- t.Fatal("unchanged directory revision cancelled the blocked preview")
853- default:
854- }
855- ac.overlays.pickerMu.Lock()
856- kept := ac.overlays.pickerRemotePreview
857- ac.overlays.pickerMu.Unlock()
858- require.Equal(t, 8, kept.Width, "blocked preview content must survive an unchanged refresh")
859-
860- // Removing the remote session moves the selection: the preview
861- // re-registers and the blocked fetch is cancelled.
862- seedRemoteDirectory(t, d)
863- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
864- select {
865- case <-cancelled:
866- case <-time.After(5 * time.Second):
867- t.Fatal("changed selection did not cancel the blocked preview")
868- }
869-}
870-
871-func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
872- d := newRemotePickerDaemon()
873- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
874- ac.resumeCapable = true
875- d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
876- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
877- ac.overlays.pickerMu.Lock()
878- refreshed := ac.overlays.picker
879- ac.overlays.pickerMu.Unlock()
880- require.NotNil(t, refreshed)
881- require.True(t, d.parkAttachment(sess, ac))
882- token := ac.resumeToken
883- d.mu.Lock()
884- parked := d.parked[token]
885- d.mu.Unlock()
886- require.NotNil(t, parked)
887-
888- d.mu.Lock()
889- retirement := d.retireParkedAttachmentLocked(token, parked)
890- d.mu.Unlock()
891- d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{retirement})
892- ac.overlays.pickerMu.Lock()
893- require.Nil(t, ac.overlays.picker, "terminal retirement closes a refreshed model in its captured generation")
894- ac.overlays.pickerMu.Unlock()
895-}
896-
897-func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
898- d := newRemotePickerDaemon()
899- firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
900- secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
901- first.resumeCapable, second.resumeCapable = true, true
902- d.publishPicker(firstSession, first, d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
903- require.True(t, d.parkAttachment(firstSession, first))
904- token := first.resumeToken
905- d.publishPicker(secondSession, second, d.newPickerModel(secondSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
906- second.resumeToken = token
907- require.True(t, d.parkAttachment(secondSession, second))
908-
909- first.overlays.pickerMu.Lock()
910- require.Nil(t, first.overlays.picker)
911- first.overlays.pickerMu.Unlock()
912- second.overlays.pickerMu.Lock()
913- require.NotNil(t, second.overlays.picker)
914- second.overlays.pickerMu.Unlock()
915-}
916-
917-func TestParkedPickerResumePreservesGeneration(t *testing.T) {
918- d := newRemotePickerDaemon()
919- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
920- ac.resumeCapable = true
921- ac.clientID = [16]byte{1, 2, 3, 4}
922- d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
923- ac.overlays.pickerMu.Lock()
924- model, generation := ac.overlays.picker, ac.overlays.pickerGeneration
925- ac.overlays.pickerMu.Unlock()
926- d.clientGone(sess, ac, ac.transport(), false)
927- token := ac.resumeToken
928- tr := &closeTrackingTransport{}
929- resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(protocol.IntentResume, sess.name, token), tr, domain.Size{Cols: 80, Rows: 24})
930- require.NoError(t, err)
931- require.True(t, ok)
932- require.Same(t, sess, resumedSess)
933- require.Same(t, ac, resumedAC)
934- ac.overlays.pickerMu.Lock()
935- require.Same(t, model, ac.overlays.picker)
936- require.Equal(t, generation, ac.overlays.pickerGeneration)
937- ac.overlays.pickerMu.Unlock()
938-}
939-
940-func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
941- d := newRemotePickerDaemon()
942- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
943- ac.resumeCapable = true
944- d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
945- require.True(t, d.parkAttachment(sess, ac))
946- token := ac.resumeToken
947- d.mu.Lock()
948- parked := d.parked[token]
949- d.mu.Unlock()
950- require.NotNil(t, parked)
951-
952- newer := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
953- d.publishPicker(sess, ac, newer, pickerNavigate, moveSourceLocator{})
954- d.expireParked(token, parked)
955- ac.overlays.pickerMu.Lock()
956- require.Same(t, newer, ac.overlays.picker)
957- ac.overlays.pickerMu.Unlock()
958-}
M internal/usecase/daemon/remote_preview_cache_test.go +0 -225
@@ -13,7 +13,6 @@ import (
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16- "github.com/bnema/vev/internal/usecase/picker"
17 )16 )
18 17
19 type remotePreviewTestClock struct {18 type remotePreviewTestClock struct {
@@ -253,230 +252,6 @@ func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
253 return immediateRemotePreviewTimer{ch: ch}252 return immediateRemotePreviewTimer{ch: ch}
254 }253 }
255 254
256-func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) {
257- d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
258- firstTarget := remotePreviewCacheTarget()
259- firstStarted := make(chan struct{})
260- firstRelease := make(chan struct{})
261- client := &remotePreviewTestClient{
262- result: remotePreviewCacheResult(firstTarget, 1),
263- started: firstStarted,
264- release: firstRelease,
265- ignoreCancel: true,
266- }
267- d.remotePreviewClient = client
268- _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
269-
270- firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
271- firstView := picker.SessionView{
272- ID: firstKey.ID(), Name: firstTarget.SessionName,
273- Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
274- RemoteKey: &firstKey, RemoteTarget: &firstTarget,
275- RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
276- }
277- firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
278- firstSelected, ok := firstModel.Selected()
279- require.True(t, ok)
280- ac.overlays.pickerMu.Lock()
281- ac.overlays.picker = firstModel
282- ac.overlays.pickerIntent = pickerNavigate
283- ac.overlays.pickerPreviewGeneration = 1
284- ac.overlays.pickerMu.Unlock()
285- d.startRemotePickerPreview(ac, firstSelected, 1)
286- <-firstStarted
287-
288- d.closePicker(ac)
289-
290- secondTarget := firstTarget
291- secondTarget.LifecycleID[0]++
292- secondTarget.LiveTabID = "tab-2"
293- client.mu.Lock()
294- client.result = remotePreviewCacheResultRune(secondTarget, 2, 'y')
295- client.started = make(chan struct{})
296- client.release = make(chan struct{})
297- client.startedSignal = false
298- client.finishedSignal = false
299- client.mu.Unlock()
300- secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
301- secondView := picker.SessionView{
302- ID: secondKey.ID(), Name: secondTarget.SessionName,
303- Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
304- RemoteKey: &secondKey, RemoteTarget: &secondTarget,
305- RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
306- }
307- secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
308- secondSelected, ok := secondModel.Selected()
309- require.True(t, ok)
310- ac.overlays.pickerMu.Lock()
311- ac.overlays.picker = secondModel
312- ac.overlays.pickerIntent = pickerNavigate
313- ac.overlays.pickerPreviewGeneration++
314- secondGeneration := ac.overlays.pickerPreviewGeneration
315- ac.overlays.pickerMu.Unlock()
316- d.startRemotePickerPreview(ac, secondSelected, secondGeneration)
317- <-client.started
318-
319- close(firstRelease)
320- close(client.release)
321- width, height := remotePickerPreviewSize(ac.sizeSnapshot())
322- for _, key := range []remotePreviewCacheKey{
323- remotePreviewKeyFor(firstTarget, width, height),
324- remotePreviewKeyFor(secondTarget, width, height),
325- } {
326- d.remotePreview.mu.Lock()
327- flight := d.remotePreview.flights[key]
328- d.remotePreview.mu.Unlock()
329- if flight != nil {
330- <-flight.done
331- }
332- }
333- var preview picker.Preview
334- require.Eventually(t, func() bool {
335- ac.overlays.pickerMu.Lock()
336- preview = ac.overlays.pickerRemotePreview
337- currentGeneration := ac.overlays.pickerPreviewGeneration
338- ac.overlays.pickerMu.Unlock()
339- return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'y'
340- }, time.Second, time.Millisecond)
341- require.Equal(t, rune('y'), preview.Rows[0][0].Rune)
342- require.Equal(t, 2, client.Calls())
343-}
344-
345-func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
346- d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
347- firstTarget := remotePreviewCacheTarget()
348- secondTarget := firstTarget
349- secondTarget.LifecycleID[0]++
350- secondTarget.LiveTabID = "tab-2"
351- client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})}
352- d.remotePreviewClient = client
353- _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
354- firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
355- secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
356- views := []picker.SessionView{
357- {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
358- {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
359- }
360- model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
361- ac.overlays.pickerMu.Lock()
362- ac.overlays.picker = model
363- ac.overlays.pickerIntent = pickerNavigate
364- ac.overlays.pickerPreviewGeneration = 1
365- ac.overlays.pickerMu.Unlock()
366- d.registerPreviewForSelection(ac)
367- <-client.started
368- require.Equal(t, firstTarget, client.LastTarget())
369-
370- client.mu.Lock()
371- client.result = remotePreviewCacheResult(secondTarget, 2)
372- client.started = make(chan struct{})
373- client.startedSignal = false
374- client.mu.Unlock()
375- ac.overlays.pickerMu.Lock()
376- model.Down()
377- ac.overlays.pickerMu.Unlock()
378- d.registerPreviewForSelection(ac)
379- <-client.started
380- require.Equal(t, secondTarget, client.LastTarget())
381- require.Equal(t, 2, client.Calls())
382-}
383-
384-func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) {
385- d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
386- firstTarget := remotePreviewCacheTarget()
387- secondTarget := firstTarget
388- secondTarget.LifecycleID[0]++
389- secondTarget.LiveTabID = "tab-2"
390- client := &remotePreviewTestClient{
391- err: errors.New("remote unavailable"),
392- started: make(chan struct{}),
393- release: make(chan struct{}),
394- }
395- d.remotePreviewClient = client
396- _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
397- firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
398- secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
399- model := picker.New([]picker.SessionView{
400- {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
401- {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach},
402- }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
403- selected, ok := model.Selected()
404- require.True(t, ok)
405- ac.overlays.pickerMu.Lock()
406- ac.overlays.picker = model
407- ac.overlays.pickerIntent = pickerNavigate
408- ac.overlays.pickerPreviewGeneration = 1
409- ac.overlays.pickerMu.Unlock()
410- d.startRemotePickerPreview(ac, selected, 1)
411- <-client.started
412-
413- ac.overlays.pickerMu.Lock()
414- ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}}
415- model.Down()
416- ac.overlays.pickerMu.Unlock()
417- close(client.release)
418-
419- require.Eventually(t, func() bool {
420- ac.overlays.pickerMu.Lock()
421- defer ac.overlays.pickerMu.Unlock()
422- return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k'
423- }, time.Second, time.Millisecond)
424-}
425-
426-func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
427- d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
428- target := remotePreviewCacheTarget()
429- firstStarted := make(chan struct{})
430- client := &remotePreviewTestClient{
431- result: remotePreviewCacheResult(target, 1),
432- started: firstStarted,
433- release: make(chan struct{}),
434- }
435- d.remotePreviewClient = client
436- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
437- key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
438- view := picker.SessionView{
439- ID: key.ID(), Name: target.SessionName,
440- Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
441- RemoteKey: &key, RemoteTarget: &target,
442- RemoteAvailability: picker.RemoteFresh, RemoteActivation: picker.RemoteAttach,
443- }
444- model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
445- selected, ok := model.Selected()
446- require.True(t, ok)
447- ac.overlays.pickerMu.Lock()
448- ac.overlays.picker = model
449- ac.overlays.pickerIntent = pickerNavigate
450- ac.overlays.pickerPreviewGeneration = 1
451- ac.overlays.pickerMu.Unlock()
452- d.startRemotePickerPreview(ac, selected, 1)
453- <-firstStarted
454-
455- secondStarted := make(chan struct{})
456- client.mu.Lock()
457- client.started = secondStarted
458- client.release = nil
459- client.startedSignal = false
460- client.mu.Unlock()
461- token := sess.captureAttachmentCapability(ac, ac.transport())
462- effect, admitted := ac.beginAttachmentEffect(token)
463- require.True(t, admitted)
464- defer effect.End()
465- require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 100, Rows: 30}))
466- <-secondStarted
467- wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468- gotWidth, gotHeight := client.LastSize()
469- require.Equal(t, wantWidth, gotWidth)
470- require.Equal(t, wantHeight, gotHeight)
471-
472- require.Eventually(t, func() bool {
473- ac.overlays.pickerMu.Lock()
474- defer ac.overlays.pickerMu.Unlock()
475- return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1
476- }, time.Second, time.Millisecond)
477- require.Equal(t, 2, client.Calls())
478-}
479-
480 func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {255 func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
481 clock := &remotePreviewTestClock{now: time.Unix(400, 0)}256 clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
482 client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}257 client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}
M internal/usecase/daemon/render.go +11 -54
@@ -30,10 +30,13 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
30 attached := len(sess.attachments) != 030 attached := len(sess.attachments) != 0
31 sess.mu.Unlock()31 sess.mu.Unlock()
32 32
33- // The normal attached render path needs no cross-session picker lookup.33+ if !active || !attached {
34- // Only inactive or headless tabs can be renderable as a picker preview.34+ // A headless tab is still renderable while a preview subscriber
35- if (!active || !attached) && !d.tabIsPickerPreview(tb) {35+ // watches it: the preview service composes its frames from this tab.
36- return false36+ rc := sess.renderCoordinator()
37+ if rc == nil || !rc.hasPreviewSubscribers() {
38+ return false
39+ }
37 }40 }
38 tb.mu.Lock()41 tb.mu.Lock()
39 defer tb.mu.Unlock()42 defer tb.mu.Unlock()
@@ -52,32 +55,6 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
52 return false55 return false
53 }56 }
54 57
55-// tabIsPickerPreview reports whether any attached client currently composes
56-// tb as a picker preview. It snapshots daemon ownership before taking overlay
57-// locks, preserving the Daemon -> session -> tab -> pane lock order.
58-func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
59- if d == nil || tb == nil {
60- return false
61- }
62- d.mu.Lock()
63- sessions := sessionsSnapshot(d.sessions)
64- d.mu.Unlock()
65- for _, sess := range sessions {
66- for _, ac := range sess.snapshotAttachments() {
67- if ac == nil || ac.overlays == nil {
68- continue
69- }
70- ac.overlays.pickerMu.Lock()
71- preview := ac.overlays.pickerPreview == tb
72- ac.overlays.pickerMu.Unlock()
73- if preview {
74- return true
75- }
76- }
77- }
78- return false
79-}
80-
81 // ptyReader is retained as a compatibility entry point for focused tests. The58 // ptyReader is retained as a compatibility entry point for focused tests. The
82 // production goroutine calls readPanePTY, whose closure owns only the pane.59 // production goroutine calls readPanePTY, whose closure owns only the pane.
83 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {60 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
@@ -360,7 +337,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
360 337
361 ac.sendMu.Lock()338 ac.sendMu.Lock()
362 if paintEffect != nil {339 if paintEffect != nil {
363- if !paintEffect.current() || ac.parkedRouteOutput.Load() {340+ if !paintEffect.current() {
364 ac.sendMu.Unlock()341 ac.sendMu.Unlock()
365 return paintRejected342 return paintRejected
366 }343 }
@@ -370,14 +347,11 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
370 entry.core().mu.Lock()347 entry.core().mu.Lock()
371 _, owned := entry.core().attachments[ac]348 _, owned := entry.core().attachments[ac]
372 entry.core().mu.Unlock()349 entry.core().mu.Unlock()
373- if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {350+ if !owned || ac.currentAttachmentSession() != entry {
374 ac.sendMu.Unlock()351 ac.sendMu.Unlock()
375 return paintRejected352 return paintRejected
376 }353 }
377 }354 }
378- if ac.parkedRouteFullPending.Load() {
379- reset = true
380- }
381 // Capacity is checked before any destructive capture. Refresh the atomic355 // Capacity is checked before any destructive capture. Refresh the atomic
382 // readiness snapshot while sendMu is held so direct test/setup mutations of356 // readiness snapshot while sendMu is held so direct test/setup mutations of
383 // the stream fields remain covered without making coordinator probes wait on357 // the stream fields remain covered without making coordinator probes wait on
@@ -416,13 +390,6 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
416 d.repaintAllAttachedClients()390 d.repaintAllAttachedClients()
417 }391 }
418 }()392 }()
419- preview := snapshotPickerPreview(nil)
420- if local && overlays.previewTab != tb {
421- preview = snapshotPickerPreview(overlays.previewTab)
422- if overlays.previewTab == nil && overlays.remotePreview.Height > 0 {
423- preview = overlays.remotePreview
424- }
425- }
426 if local {393 if local {
427 d.refreshSessionFocusedTitles(sess)394 d.refreshSessionFocusedTitles(sess)
428 }395 }
@@ -464,14 +431,13 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
464 431
465 capturedOverlays := capturedOverlayRenderState{432 capturedOverlays := capturedOverlayRenderState{
466 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,433 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
467- pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,434+ paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
468 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,435 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
469 }436 }
470 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)437 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
471 state, ok := entry.captureRenderState(ac, renderCaptureRequest{438 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
472 bars: bars,439 bars: bars,
473 overlays: capturedOverlays,440 overlays: capturedOverlays,
474- preview: preview,
475 floatingCfg: floatingCfg,441 floatingCfg: floatingCfg,
476 styles: applied.Resolved.Styles,442 styles: applied.Resolved.Styles,
477 styleGeneration: applied.Generation,443 styleGeneration: applied.Generation,
@@ -487,12 +453,6 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
487 if ac.renderStages.capture != nil {453 if ac.renderStages.capture != nil {
488 ac.renderStages.capture()454 ac.renderStages.capture()
489 }455 }
490- if local && overlays.pickerActive && overlays.previewTab == tb {
491- previewState := *state
492- previewState.overlays = capturedOverlayRenderState{}
493- previewState.reset = true
494- state.preview = pickerPreviewFromCapturedRender(previewState)
495- }
496 captureOverlayLayers(state, overlays, paletteCfg)456 captureOverlayLayers(state, overlays, paletteCfg)
497 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)457 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
498 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)458 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
@@ -500,10 +460,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
500 if ac.renderStages.compose != nil {460 if ac.renderStages.compose != nil {
501 ac.renderStages.compose()461 ac.renderStages.compose()
502 }462 }
503- full := state.reset463+ d.emitFrame(entry, ac, state, composed, &marks)
504- if d.emitFrame(entry, ac, state, composed, &marks) && full {
505- ac.parkedRouteFullPending.Store(false)
506- }
507 return paintEmitted464 return paintEmitted
508 }465 }
509 466
M internal/usecase/daemon/render_capture.go +13 -30
@@ -7,7 +7,6 @@ import (
7 "github.com/bnema/vev/internal/protocol"7 "github.com/bnema/vev/internal/protocol"
8 scopy "github.com/bnema/vev/internal/usecase/copy"8 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/internal/usecase/picker"
11 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
12 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
13 )12 )
@@ -65,7 +64,6 @@ type capturedRenderState struct {
65 // that cannot emit graphics. Capture keeps the scene out of the output but64 // that cannot emit graphics. Capture keeps the scene out of the output but
66 // lets the daemon issue one bounded user warning after pane locks release.65 // lets the daemon issue one bounded user warning after pane locks release.
67 suppressedGraphics bool66 suppressedGraphics bool
68- preview picker.Preview
69 cursor capturedCursorInputs67 cursor capturedCursorInputs
70 tabGeneration uint6468 tabGeneration uint64
71 floatingGeneration uint6469 floatingGeneration uint64
@@ -125,15 +123,15 @@ type capturedFloatingRenderState struct {
125 }123 }
126 124
127 type capturedOverlayRenderState struct {125 type capturedOverlayRenderState struct {
128- copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool126+ copyActive, copySearchActive, paletteActive, promptActive bool
129- noticesOverlayActive, resizeActive bool127+ noticesOverlayActive, resizeActive bool
130- copyMode *scopy.Mode128+ copyMode *scopy.Mode
131- copyPaneID layout.PaneID129+ copyPaneID layout.PaneID
132- statusFeedback string130+ statusFeedback string
133- paletteGuidance string131+ paletteGuidance string
134- copySearch, picker, palette, prompt, noticesOverlay capturedModal132+ copySearch, palette, prompt, noticesOverlay capturedModal
135- notices []domain.Notification133+ notices []domain.Notification
136- noticeOverflow int134+ noticeOverflow int
137 }135 }
138 136
139 type capturedModal struct {137 type capturedModal struct {
@@ -145,7 +143,7 @@ type capturedModal struct {
145 }143 }
146 144
147 func (o capturedOverlayRenderState) active() bool {145 func (o capturedOverlayRenderState) active() bool {
148- return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive146+ return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
149 }147 }
150 148
151 type capturedCursorInputs struct {149 type capturedCursorInputs struct {
@@ -269,7 +267,6 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
269 type renderCaptureRequest struct {267 type renderCaptureRequest struct {
270 bars barState268 bars barState
271 overlays capturedOverlayRenderState269 overlays capturedOverlayRenderState
272- preview picker.Preview
273 floatingCfg domain.FloatingConfig270 floatingCfg domain.FloatingConfig
274 styles themeui.Styles271 styles themeui.Styles
275 styleGeneration uint64272 styleGeneration uint64
@@ -289,7 +286,6 @@ func captureLocalRenderState(
289 ) (*capturedRenderState, bool) {286 ) (*capturedRenderState, bool) {
290 bars := request.bars287 bars := request.bars
291 overlays := request.overlays288 overlays := request.overlays
292- preview := request.preview
293 floatingCfg := request.floatingCfg289 floatingCfg := request.floatingCfg
294 reset := request.reset290 reset := request.reset
295 lease := request.lease291 lease := request.lease
@@ -335,14 +331,9 @@ func captureLocalRenderState(
335 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)331 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
336 bars.rankedRecent = scratch.ranked332 bars.rankedRecent = scratch.ranked
337 }333 }
338- if len(preview.Rows) > 0 {
339- preview = clonePickerPreview(preview)
340- } else {
341- preview = picker.Preview{Width: preview.Width, Height: preview.Height}
342- }
343-
344 tb.mu.Lock()334 tb.mu.Lock()
345 defer tb.mu.Unlock()335 defer tb.mu.Unlock()
336+
346 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}337 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
347 var focus layout.PaneID338 var focus layout.PaneID
348 var focusedPaneID domain.PaneStableID339 var focusedPaneID domain.PaneStableID
@@ -392,7 +383,7 @@ func captureLocalRenderState(
392 uiFence: request.uiFence,383 uiFence: request.uiFence,
393 focusedPaneID: focusedPaneID,384 focusedPaneID: focusedPaneID,
394 styles: request.styles, styleGeneration: request.styleGeneration,385 styles: request.styles, styleGeneration: request.styleGeneration,
395- overlays: overlays, preview: preview,386+ overlays: overlays,
396 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},387 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
397 floatingGeneration: tb.floating.generation,388 floatingGeneration: tb.floating.generation,
398 receipts: scratch.receipts[:0],389 receipts: scratch.receipts[:0],
@@ -486,18 +477,10 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
486 477
487 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {478 func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
488 style, hasStyle := p.screen.CursorStyle()479 style, hasStyle := p.screen.CursorStyle()
489- hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive480+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
490 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}481 return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
491 }482 }
492 483
493-func clonePickerPreview(in picker.Preview) picker.Preview {
494- out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
495- for i := range in.Rows {
496- out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
497- }
498- return out
499-}
500-
501 // appendStackPaneIDs returns exactly the panes whose solved placement can own a484 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
502 // stack title bar. The caller holds tab.mu, and dst is attachment scratch.485 // stack title bar. The caller holds tab.mu, and dst is attachment scratch.
503 func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {486 func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
M internal/usecase/daemon/render_coordinator.go +12 -0
@@ -631,6 +631,18 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient, generatio
631 c.mu.Unlock()631 c.mu.Unlock()
632 }632 }
633 633
634+// hasPreviewSubscribers reports whether any presentation is currently
635+// previewing this coordinator's tab. A headless tab stays renderable while a
636+// preview subscriber watches it.
637+func (c *renderCoordinator) hasPreviewSubscribers() bool {
638+ if c == nil {
639+ return false
640+ }
641+ c.mu.Lock()
642+ defer c.mu.Unlock()
643+ return len(c.previewWakes) != 0
644+}
645+
634 func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {646 func (c *renderCoordinator) attachmentRegisteredLocked(ac *attachedClient) bool {
635 if ac == nil {647 if ac == nil {
636 return false648 return false
M internal/usecase/daemon/render_coordinator_test.go +16 -4
@@ -1153,7 +1153,12 @@ func TestProducerInvalidations(t *testing.T) {
1153 name string1153 name string
1154 tabs int1154 tabs int
1155 producer string1155 producer string
1156- run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)1156+ // interaction marks producers that publish their own control frames
1157+ // (the picker offer and snapshot): the coordinator-invalidation
1158+ // assertion below covers their repaint, and those frames are pinned
1159+ // by the interaction suites.
1160+ interaction bool
1161+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1157 }{1162 }{
1158 {1163 {
1159 file: "attention.go",1164 file: "attention.go",
@@ -1216,10 +1221,12 @@ func TestProducerInvalidations(t *testing.T) {
1216 },1221 },
1217 },1222 },
1218 {1223 {
1219- file: "picker.go",1224+ file: "picker.go",
1220- name: "picker entry",1225+ name: "picker entry",
1226+ interaction: true,
1221 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1227 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1222- d.enterPicker(sess, ac)1228+ effect := admitPickerEffectForTest(t, sess, ac)
1229+ require.NoError(t, d.openPickerForAttachment(ac, effect, protocol.PickerIntentNavigation, moveSourceLocator{}, 0))
1223 },1230 },
1224 },1231 },
1225 {1232 {
@@ -1287,6 +1294,11 @@ func TestProducerInvalidations(t *testing.T) {
1287 require.Equal(t, tc.producer, inv.producer)1294 require.Equal(t, tc.producer, inv.producer)
1288 }1295 }
1289 requireNoInvalidation(t, invs)1296 requireNoInvalidation(t, invs)
1297+ if tc.interaction {
1298+ // The picker publishes offer/snapshot/restore frames of its own;
1299+ // drain them so the frame assertion below covers the repaint.
1300+ drainAllFrames(sends)
1301+ }
1290 requireNoCoordinatorOutputFrame(t, sends)1302 requireNoCoordinatorOutputFrame(t, sends)
1291 })1303 })
1292 }1304 }
M internal/usecase/daemon/render_damage_receipt_test.go +0 -5
@@ -8,7 +8,6 @@ import (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol/wire"9 "github.com/bnema/vev/internal/protocol/wire"
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
13 )12 )
14 13
@@ -58,7 +57,6 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
58 state, ok := captureRenderState(sess, ac, renderCaptureRequest{57 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
59 bars: barState{},58 bars: barState{},
60 overlays: capturedOverlayRenderState{},59 overlays: capturedOverlayRenderState{},
61- preview: pickerPreviewEmpty(),
62 floatingCfg: domain.FloatingConfig{},60 floatingCfg: domain.FloatingConfig{},
63 reset: false,61 reset: false,
64 lease: nil,62 lease: nil,
@@ -254,7 +252,6 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
254 state, ok := captureRenderState(sess, ac, renderCaptureRequest{252 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
255 bars: barState{},253 bars: barState{},
256 overlays: capturedOverlayRenderState{},254 overlays: capturedOverlayRenderState{},
257- preview: pickerPreviewEmpty(),
258 floatingCfg: domain.FloatingConfig{},255 floatingCfg: domain.FloatingConfig{},
259 reset: false,256 reset: false,
260 lease: nil,257 lease: nil,
@@ -262,5 +259,3 @@ func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClien
262 require.True(t, ok)259 require.True(t, ok)
263 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)260 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
264 }261 }
265-
266-func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
M internal/usecase/daemon/render_pipeline.go +4 -20
@@ -11,7 +11,6 @@ import (
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/notices"12 "github.com/bnema/vev/internal/usecase/notices"
13 "github.com/bnema/vev/internal/usecase/palette"13 "github.com/bnema/vev/internal/usecase/palette"
14- "github.com/bnema/vev/internal/usecase/picker"
15 "github.com/bnema/vev/internal/usecase/prompt"14 "github.com/bnema/vev/internal/usecase/prompt"
16 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
@@ -175,7 +174,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
175 damage = floatingDamage174 damage = floatingDamage
176 }175 }
177 copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&176 copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
178- !state.overlays.copySearchActive && !state.overlays.pickerActive &&177+ !state.overlays.copySearchActive &&
179 !state.overlays.paletteActive && !state.overlays.promptActive &&178 !state.overlays.paletteActive && !state.overlays.promptActive &&
180 !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible179 !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
181 var copyViewport copyViewportState180 var copyViewport copyViewportState
@@ -269,7 +268,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
269 return268 return
270 }269 }
271 o := &state.overlays270 o := &state.overlays
272- o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive271+ o.copyActive, o.copySearchActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.paletteActive, snap.promptActive
273 o.noticesOverlayActive = snap.noticesOverlayActive272 o.noticesOverlayActive = snap.noticesOverlayActive
274 o.copyMode = snap.copyMode273 o.copyMode = snap.copyMode
275 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow274 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
@@ -288,21 +287,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
288 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}287 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
289 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})288 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
290 }289 }
291- if snap.pickerActive && snap.pickerModel != nil {
292- presentation := pickerModal.Resolve(size)
293- title := snap.pickerTitle
294- if searchTitle := snap.pickerModel.SearchTitle(presentation.Bounds.Width - 2); searchTitle != "" {
295- title = searchTitle
296- } else if title == "" {
297- title = pickerModal.Title
298- }
299- o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
300- stoppedStyle := styles.PickerName
301- stoppedStyle.Attrs |= renderer.AttrDim
302- stoppedStyle.Italic = true
303- renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle, Status: styles.PickerDescription, SearchMatch: styles.SearchSelection, SelectionMatch: styles.HintKey}
304- o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
305- }
306 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {290 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
307 presentation := noticesModal.Resolve(size)291 presentation := noticesModal.Resolve(size)
308 o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}292 o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
@@ -363,8 +347,8 @@ func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.R
363 func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {347 func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
364 o := state.overlays348 o := state.overlays
365 // Paint in reverse keyboard priority so the same layer that owns input is349 // Paint in reverse keyboard priority so the same layer that owns input is
366- // visually topmost: prompt > palette > picker > notices > copy search.350+ // visually topmost: prompt > palette > notices > copy search.
367- for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {351+ for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.palette, o.prompt} {
368 if !modal.active {352 if !modal.active {
369 continue353 continue
370 }354 }
M internal/usecase/daemon/render_pipeline_test.go +1 -8
@@ -21,7 +21,6 @@ import (
21 "github.com/bnema/vev/internal/usecase/layout"21 "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/notices"22 "github.com/bnema/vev/internal/usecase/notices"
23 "github.com/bnema/vev/internal/usecase/palette"23 "github.com/bnema/vev/internal/usecase/palette"
24- "github.com/bnema/vev/internal/usecase/picker"
25 promptui "github.com/bnema/vev/internal/usecase/prompt"24 promptui "github.com/bnema/vev/internal/usecase/prompt"
26 themeui "github.com/bnema/vev/internal/usecase/theme"25 themeui "github.com/bnema/vev/internal/usecase/theme"
27 "github.com/bnema/vev/internal/usecase/ui"26 "github.com/bnema/vev/internal/usecase/ui"
@@ -592,8 +591,7 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
592 higher string591 higher string
593 }{592 }{
594 {name: "notices above copy search", lower: "copy search", higher: "notices"},593 {name: "notices above copy search", lower: "copy search", higher: "notices"},
595- {name: "picker above notices", lower: "notices", higher: "picker"},594+ {name: "palette above notices", lower: "notices", higher: "palette"},
596- {name: "palette above picker", lower: "picker", higher: "palette"},
597 {name: "prompt above palette", lower: "palette", higher: "prompt"},595 {name: "prompt above palette", lower: "palette", higher: "prompt"},
598 } {596 } {
599 t.Run(tt.name, func(t *testing.T) {597 t.Run(tt.name, func(t *testing.T) {
@@ -604,8 +602,6 @@ func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
604 state.overlays.copySearch = modal602 state.overlays.copySearch = modal
605 case "notices":603 case "notices":
606 state.overlays.noticesOverlay = modal604 state.overlays.noticesOverlay = modal
607- case "picker":
608- state.overlays.picker = modal
609 case "palette":605 case "palette":
610 state.overlays.palette = modal606 state.overlays.palette = modal
611 case "prompt":607 case "prompt":
@@ -634,8 +630,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
634 snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)630 snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
635 snap := &overlayRenderSnapshot{631 snap := &overlayRenderSnapshot{
636 copySearchModel: visualsearch.New(snapshot),632 copySearchModel: visualsearch.New(snapshot),
637- pickerActive: true,
638- pickerModel: picker.New(nil, picker.SelectionConfig{}),
639 noticesOverlayActive: true,633 noticesOverlayActive: true,
640 noticesOverlayModel: notices.New(nil, time.Time{}),634 noticesOverlayModel: notices.New(nil, time.Time{}),
641 paletteActive: true,635 paletteActive: true,
@@ -648,7 +642,6 @@ func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
648 642
649 for name, modal := range map[string]capturedModal{643 for name, modal := range map[string]capturedModal{
650 "copy search": state.overlays.copySearch,644 "copy search": state.overlays.copySearch,
651- "picker": state.overlays.picker,
652 "notices": state.overlays.noticesOverlay,645 "notices": state.overlays.noticesOverlay,
653 "palette": state.overlays.palette,646 "palette": state.overlays.palette,
654 "prompt": state.overlays.prompt,647 "prompt": state.overlays.prompt,
M internal/usecase/daemon/render_test.go +2 -66
@@ -97,29 +97,6 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
97 }97 }
98 }98 }
99 99
100-func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
101- p, release := newBlockingPTY(t)
102- defer release()
103- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
104- ac.setThemeForTest(backdropTheme())
105- client := vt.NewScreen(80, 25)
106- pane := sess.tabs[0].focusedPane()
107- pane.screen.Write([]byte("X"))
108-
109- d.enterPicker(sess, ac)
110- mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
111- pickerTitle := client.Cell(31, 2)
112- require.Equal(t, 'S', pickerTitle.Rune, "fixture must address the picker title")
113- undimmedPane := client.Cell(0, 1)
114-
115- d.enterPalette(sess, ac)
116- mustApplyOutput(t, client, awaitFrame(t, sends, wire.MsgOutput))
117- dimmedPickerTitle := pickerTitle
118- dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style).Canonical()
119- require.Equal(t, dimmedPickerTitle, client.Cell(31, 2), "the lower-priority picker must be part of the palette backdrop")
120- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style).Canonical(), client.Cell(0, 1).Style.Canonical(), "pane content outside overlays must use the theme dim style")
121-}
122-
123 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {100 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
124 p, release := newBlockingPTY(t)101 p, release := newBlockingPTY(t)
125 defer release()102 defer release()
@@ -388,12 +365,9 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
388 d.clock = clock.clock365 d.clock = clock.clock
389 rc := d.attachCoordinator(target, nil, targetClient, true)366 rc := d.attachCoordinator(target, nil, targetClient, true)
390 367
391- // A viewer in a different session is previewing the headless target.368+ // A viewer in a different session subscribes to the headless target.
392 viewer := &attachedClient{}369 viewer := &attachedClient{}
393 viewer.initOverlays()370 viewer.initOverlays()
394- viewer.overlays.pickerMu.Lock()
395- viewer.overlays.pickerPreview = target.tabs[0]
396- viewer.overlays.pickerMu.Unlock()
397 d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}371 d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
398 previews := make(chan renderWake, 2)372 previews := make(chan renderWake, 2)
399 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })373 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
@@ -495,25 +469,6 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
495 })469 })
496 }470 }
497 471
498-// S2 keeps damage pending until the owning render capture consumes it. In
499-// particular, a pane becoming invisible must not let a PTY reader erase data
500-// that a later attachment or picker preview needs to render.
501-func TestPaneRenderableActiveAttachmentDoesNotScanPickerPreviews(t *testing.T) {
502- p, release := newBlockingPTY(t)
503- defer release()
504- d, sess, _, _ := newManualSessionWithPTYs(t, p)
505- tb := testAttachmentTab(sess)
506- pane := tb.focusedPane()
507-
508- // Holding daemon ownership makes a picker scan block. An active attached
509- // tab must decide renderability from its session state without that scan.
510- d.mu.Lock()
511- defer d.mu.Unlock()
512- done := make(chan bool, 1)
513- go func() { done <- d.paneRenderable(sess, tb, pane) }()
514- require.True(t, awaitTestValue(t, done, "active attached renderability scanned picker previews"))
515-}
516-
517 func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {472 func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
518 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan wire.Frame) {473 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan wire.Frame) {
519 t.Helper()474 t.Helper()
@@ -561,23 +516,11 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
561 }516 }
562 })517 })
563 518
564- t.Run("retains active and picker-preview pane damage", func(t *testing.T) {519+ t.Run("retains active pane damage", func(t *testing.T) {
565 d, sess, tb, p, _ := newFixture(t)520 d, sess, tb, p, _ := newFixture(t)
566 p.screen.Write([]byte("active"))521 p.screen.Write([]byte("active"))
567 _ = d.paneRenderable(sess, tb, p)522 _ = d.paneRenderable(sess, tb, p)
568 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")523 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
569- p.screen.ClearDamage()
570-
571- clearAttachmentsForTest(sess)
572- viewer := &attachedClient{}
573- viewer.initOverlays()
574- viewer.overlays.pickerMu.Lock()
575- viewer.overlays.pickerPreview = tb
576- viewer.overlays.pickerMu.Unlock()
577- d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", attachments: map[*attachedClient]struct{}{viewer: {}}}}
578- p.screen.Write([]byte("preview"))
579- _ = d.paneRenderable(sess, tb, p)
580- require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
581 })524 })
582 }525 }
583 526
@@ -797,13 +740,6 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
797 visible: "Commands",740 visible: "Commands",
798 notVisible: "Commands",741 notVisible: "Commands",
799 },742 },
800- {
801- name: "picker",
802- open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
803- close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true, nil) },
804- visible: "Sessions",
805- notVisible: "Sessions",
806- },
807 {743 {
808 name: "copy mode",744 name: "copy mode",
809 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },745 open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
M internal/usecase/daemon/resume.go +6 -11
@@ -217,16 +217,17 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
217 if !d.prepareParkAttachment(sess, ac) {217 if !d.prepareParkAttachment(sess, ac) {
218 return false218 return false
219 }219 }
220+ // The client recreates picker state after reconnect. Retire the daemon-side
221+ // interaction before publishing the parked attachment so resumed input is
222+ // never swallowed by a stale overlay.
223+ d.retirePicker(ac)
220 // A parked attachment has no session owner. Release pane snapshots before224 // A parked attachment has no session owner. Release pane snapshots before
221 // publishing it in the daemon registry so headless pane/session teardown225 // publishing it in the daemon registry so headless pane/session teardown
222 // cannot leave closed panes retained through its capture cache. This must226 // cannot leave closed panes retained through its capture cache. This must
223 // remain outside d.mu: sendMu is ordered before the daemon lock.227 // remain outside d.mu: sendMu is ordered before the daemon lock.
224 ac.clearCaptureFrames()228 ac.clearCaptureFrames()
225- var pickerGeneration, paletteGeneration uint64229+ var paletteGeneration uint64
226 if ac.overlays != nil {230 if ac.overlays != nil {
227- ac.overlays.pickerMu.Lock()
228- pickerGeneration = ac.overlays.pickerGeneration
229- ac.overlays.pickerMu.Unlock()
230 ac.overlays.paletteMu.Lock()231 ac.overlays.paletteMu.Lock()
231 paletteGeneration = ac.overlays.paletteGeneration232 paletteGeneration = ac.overlays.paletteGeneration
232 ac.overlays.paletteMu.Unlock()233 ac.overlays.paletteMu.Unlock()
@@ -253,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
253 grace := d.resumeParkGrace254 grace := d.resumeParkGrace
254 timer := d.clock.NewTimer(grace)255 timer := d.clock.NewTimer(grace)
255 parked := &parkedAttachment{256 parked := &parkedAttachment{
256- sess: sess, ac: ac, pickerGeneration: pickerGeneration, paletteGeneration: paletteGeneration,257+ sess: sess, ac: ac, paletteGeneration: paletteGeneration,
257 timer: timer, done: make(chan struct{}),258 timer: timer, done: make(chan struct{}),
258 }259 }
259 ac.parked = true260 ac.parked = true
@@ -297,7 +298,6 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
297 }298 }
298 d.removeParkedLocked(token, parked)299 d.removeParkedLocked(token, parked)
299 d.mu.Unlock()300 d.mu.Unlock()
300- d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
301 d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)301 d.closePaletteIfCurrent(parked.ac, parked.paletteGeneration)
302 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())302 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
303 return303 return
@@ -400,7 +400,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
400 rearmed := &parkedAttachment{400 rearmed := &parkedAttachment{
401 sess: parked.sess,401 sess: parked.sess,
402 ac: ac,402 ac: ac,
403- pickerGeneration: parked.pickerGeneration,
404 paletteGeneration: parked.paletteGeneration,403 paletteGeneration: parked.paletteGeneration,
405 timer: d.clock.NewTimer(d.resumeParkGrace),404 timer: d.clock.NewTimer(d.resumeParkGrace),
406 done: make(chan struct{}),405 done: make(chan struct{}),
@@ -420,7 +419,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
420 419
421 type parkedAttachmentRetirement struct {420 type parkedAttachmentRetirement struct {
422 parked *parkedAttachment421 parked *parkedAttachment
423- pickerGeneration uint64
424 paletteGeneration uint64422 paletteGeneration uint64
425 transport transportSnapshot423 transport transportSnapshot
426 }424 }
@@ -434,7 +432,6 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
434 parked.ac.invalidateRetiredAttachmentCapability()432 parked.ac.invalidateRetiredAttachmentCapability()
435 return parkedAttachmentRetirement{433 return parkedAttachmentRetirement{
436 parked: parked,434 parked: parked,
437- pickerGeneration: parked.pickerGeneration,
438 paletteGeneration: parked.paletteGeneration,435 paletteGeneration: parked.paletteGeneration,
439 transport: parked.ac.transportSnapshot(),436 transport: parked.ac.transportSnapshot(),
440 }437 }
@@ -470,7 +467,6 @@ func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmen
470 }467 }
471 retirement.parked.closeDone()468 retirement.parked.closeDone()
472 ac := retirement.parked.ac469 ac := retirement.parked.ac
473- d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
474 d.closePaletteIfCurrent(ac, retirement.paletteGeneration)470 d.closePaletteIfCurrent(ac, retirement.paletteGeneration)
475 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))471 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
476 }472 }
@@ -559,7 +555,6 @@ func (d *Daemon) resumeLiveAttachment(h protocol.Hello, tr ports.ServerConnectio
559 rc.noteDetach(ac)555 rc.noteDetach(ac)
560 }556 }
561 sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")557 sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
562- d.unregisterPreview(ac)
563 if !d.parkAttachment(sess, ac) {558 if !d.parkAttachment(sess, ac) {
564 // Detach already published; retire the captured link exactly once so559 // Detach already published; retire the captured link exactly once so
565 // shutdown cannot leave an orphaned open transport with no owner.560 // shutdown cannot leave an orphaned open transport with no owner.
M internal/usecase/daemon/route_retirement_test.go +1 -1
@@ -45,7 +45,7 @@ func TestRemoteDirectoryDeletionRetiresHistoryWithPickerClosed(t *testing.T) {
45 ref := protocol.RouteRef{Key: 3, Generation: 2}45 ref := protocol.RouteRef{Key: 3, Generation: 2}
46 ac.setRouteAttentionSubscription(protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{{Ref: ref, Target: target, SourceKey: protocol.RemoteInventorySourceKey("remote")}}}, ac.transportSnapshot(), d.clock.Now())46 ac.setRouteAttentionSubscription(protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{{Ref: ref, Target: target, SourceKey: protocol.RemoteInventorySourceKey("remote")}}}, ac.transportSnapshot(), d.clock.Now())
47 seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{Endpoint: "remote", InventoryKnown: true, Availability: domain.RemoteAvailabilityReachable, LastAttempt: d.clock.Now().Add(time.Second), LastSuccess: d.clock.Now().Add(2 * time.Second)})47 seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{Endpoint: "remote", InventoryKnown: true, Availability: domain.RemoteAvailabilityReachable, LastAttempt: d.clock.Now().Add(time.Second), LastSuccess: d.clock.Now().Add(2 * time.Second)})
48- require.False(t, ac.overlays.pickerActive())48+ require.False(t, ac.overlays.pickerClientActive())
49 d.refreshRemoteDirectoryViews()49 d.refreshRemoteDirectoryViews()
50 frame := awaitFrame(t, sends, wire.MsgRouteRetired)50 frame := awaitFrame(t, sends, wire.MsgRouteRetired)
51 message, err := wire.UnmarshalRouteRetired(frame.Payload)51 message, err := wire.UnmarshalRouteRetired(frame.Payload)
M internal/usecase/daemon/same_peer_switch.go +0 -1
@@ -96,7 +96,6 @@ func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request p
96 }96 }
97 97
98 if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted {98 if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted {
99- d.closePicker(effect.ac)
100 fresh.End()99 fresh.End()
101 }100 }
102 d.touchMRU(target)101 d.touchMRU(target)
M internal/usecase/daemon/session.go +0 -3
@@ -1195,7 +1195,6 @@ func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool,
1195 d.log.Info("tab closed", "session", name)1195 d.log.Info("tab closed", "session", name)
1196 markSnapshotDirty(sess)1196 markSnapshotDirty(sess)
1197 1197
1198- d.clearDestroyedTabPreview(tb)
1199 tb.mu.Lock()1198 tb.mu.Lock()
1200 panes := tb.panesSnapshot()1199 panes := tb.panesSnapshot()
1201 tb.mu.Unlock()1200 tb.mu.Unlock()
@@ -1814,7 +1813,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1814 rc.waitForTimerWorkers()1813 rc.waitForTimerWorkers()
1815 }1814 }
1816 for _, attachment := range attachments {1815 for _, attachment := range attachments {
1817- d.unregisterPreview(attachment.ac)
1818 attachment.ac.clearCaptureFrames()1816 attachment.ac.clearCaptureFrames()
1819 }1817 }
1820 1818
@@ -1828,7 +1826,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1828 sess.clipFiles = nil1826 sess.clipFiles = nil
1829 sess.mu.Unlock()1827 sess.mu.Unlock()
1830 for _, tb := range tabs {1828 for _, tb := range tabs {
1831- d.clearDestroyedTabPreview(tb)
1832 for _, attachment := range attachments {1829 for _, attachment := range attachments {
1833 d.teardownFloating(tb, attachment.ac)1830 d.teardownFloating(tb, attachment.ac)
1834 }1831 }
M internal/usecase/daemon/session_back.go +2 -1
@@ -23,7 +23,8 @@ func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error {
23 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {23 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
24 return nil24 return nil
25 }25 }
26- if target, ok := effect.ac.routeAttentionTarget(snapshot.Previous); ok {26+ if subscribed, ok := effect.ac.routeAttentionTarget(snapshot.Previous); ok && subscribed.SourceKey == "" {
27+ target := subscribed.Target
27 targetSession, _, live := d.samePeerTarget(protocol.SamePeerSwitchRequest{Target: target})28 targetSession, _, live := d.samePeerTarget(protocol.SamePeerSwitchRequest{Target: target})
28 if live && targetSession != effect.sess {29 if live && targetSession != effect.sess {
29 return offerSamePeerAttachTarget(effect, target)30 return offerSamePeerAttachTarget(effect, target)
M internal/usecase/daemon/session_capabilities_test.go +9 -6
@@ -4,6 +4,8 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/vev/internal/protocol"
8+
7 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
8 )10 )
9 11
@@ -15,23 +17,24 @@ func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
15 }17 }
16 }18 }
17 19
18-func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {20+func TestOpenPickerForIntentRejectsNonYieldingSource(t *testing.T) {
19 d, sess, ac, _ := newManualSessionWithPTYs(t)21 d, sess, ac, _ := newManualSessionWithPTYs(t)
20 sess.caps = sessionCapabilities{cannotYieldMoves: true}22 sess.caps = sessionCapabilities{cannotYieldMoves: true}
23+ effect := admitPickerEffectForTest(t, sess, ac)
21 24
22 tests := []struct {25 tests := []struct {
23 name string26 name string
24- intent pickerIntent27+ intent protocol.PickerIntent
25 wantErr error28 wantErr error
26 }{29 }{
27- {name: "pickerMoveTab", intent: pickerMoveTab, wantErr: errSessionCannotYieldMoves},30+ {name: "move tab", intent: protocol.PickerIntentMoveTab, wantErr: errSessionCannotYieldMoves},
28- {name: "pickerNavigate", intent: pickerNavigate, wantErr: nil},31+ {name: "navigation", intent: protocol.PickerIntentNavigation, wantErr: nil},
29 }32 }
30 for _, tt := range tests {33 for _, tt := range tests {
31 t.Run(tt.name, func(t *testing.T) {34 t.Run(tt.name, func(t *testing.T) {
32- err := d.enterPickerForIntent(sess, ac, tt.intent, moveSourceLocator{})35+ err := d.openPickerForAttachment(ac, effect, tt.intent, moveSourceLocator{}, 0)
33 if !errors.Is(err, tt.wantErr) {36 if !errors.Is(err, tt.wantErr) {
34- t.Fatalf("enterPickerForIntent error = %v, want %v", err, tt.wantErr)37+ t.Fatalf("openPickerForAttachment error = %v, want %v", err, tt.wantErr)
35 }38 }
36 })39 })
37 }40 }
M internal/usecase/daemon/session_view.go +5 -7
@@ -4,7 +4,6 @@ import (
4 "time"4 "time"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/usecase/picker"
8 )7 )
9 8
10 // viewOptions controls optional (costly) fields captured by snapshotView.9 // viewOptions controls optional (costly) fields captured by snapshotView.
@@ -92,16 +91,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
92 return view91 return view
93 }92 }
94 93
95-// pickerView renders this snapshot as the picker's value type. Field-for-field94+// pickerView renders this snapshot as the picker's daemon-private value type.
96-// equivalent to the inline construction previously in pickerViews.95+func (view sessionView) pickerView() pickerSessionView {
97-func (view sessionView) pickerView() picker.SessionView {96+ out := pickerSessionView{
98- out := picker.SessionView{
99 ID: view.id,97 ID: view.id,
100 Incarnation: view.incarnation,98 Incarnation: view.incarnation,
101 Name: view.name,99 Name: view.name,
102 TargetName: view.name,100 TargetName: view.name,
103 Active: view.defaultTab,101 Active: view.defaultTab,
104- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),102+ Tabs: make([]pickerTabEntry, 0, len(view.tabs)),
105 }103 }
106 if !view.ephemeral {104 if !view.ephemeral {
107 createdAt := view.createdAt105 createdAt := view.createdAt
@@ -109,7 +107,7 @@ func (view sessionView) pickerView() picker.SessionView {
109 }107 }
110 attention := false108 attention := false
111 for _, tb := range view.tabs {109 for _, tb := range view.tabs {
112- out.Tabs = append(out.Tabs, picker.TabEntry{110+ out.Tabs = append(out.Tabs, pickerTabEntry{
113 TabID: tb.id,111 TabID: tb.id,
114 Name: tb.name,112 Name: tb.name,
115 Detail: tabTitleDetail(tb.name, tb.focusedTitle),113 Detail: tabTitleDetail(tb.name, tb.focusedTitle),
M internal/usecase/daemon/status.go +3 -17
@@ -283,34 +283,20 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached
283 if d != nil {283 if d != nil {
284 state.attentionFrame = d.attentionFrame()284 state.attentionFrame = d.attentionFrame()
285 }285 }
286- homePicker := ac != nil && ac.startupOverlay == protocol.StartupOverlaySessionPicker && ac.navigationCapabilities&protocol.NavigationCapabilityBack != 0286+ if cur != nil {
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 {
301 includeTerminalTitle := true287 includeTerminalTitle := true
302 if d != nil {288 if d != nil {
303 includeTerminalTitle = d.currentTabsConfig().TerminalTitle289 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
304 }290 }
305 state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)291 state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
306 }292 }
307- if !homePicker && cur != nil && d != nil {293+ if cur != nil && d != nil {
308 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)294 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
309 }295 }
310 if ranked != nil {296 if ranked != nil {
311 state.rankedRecent = ranked297 state.rankedRecent = ranked
312 } else if routeSnapshot.Generation != 0 {298 } else if routeSnapshot.Generation != 0 {
313- state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot)299+ state.mru = formatRecentRouteSnapshot(routeSnapshot)
314 }300 }
315 return state301 return state
316 }302 }
M internal/usecase/daemon/status_test.go +0 -2
@@ -18,7 +18,6 @@ import (
18 "github.com/bnema/vev/internal/usecase/command"18 "github.com/bnema/vev/internal/usecase/command"
19 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/internal/usecase/palette"20 "github.com/bnema/vev/internal/usecase/palette"
21- "github.com/bnema/vev/internal/usecase/picker"
22 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
23 "github.com/bnema/vev/internal/usecase/ui"22 "github.com/bnema/vev/internal/usecase/ui"
24 )23 )
@@ -1026,7 +1025,6 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1026 state, ok := captureRenderState(sess, ac, renderCaptureRequest{1025 state, ok := captureRenderState(sess, ac, renderCaptureRequest{
1027 bars: bars,1026 bars: bars,
1028 overlays: capturedOverlayRenderState{},1027 overlays: capturedOverlayRenderState{},
1029- preview: picker.Preview{},
1030 floatingCfg: domain.FloatingConfig{},1028 floatingCfg: domain.FloatingConfig{},
1031 reset: false,1029 reset: false,
1032 lease: nil,1030 lease: nil,
A internal/usecase/daemon/tab_test_helpers_test.go +19 -0
@@ -0,0 +1,19 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// newTestTabWithContext builds one tab wired to a caller-owned context, so a
11+// test can cancel the tab and its panes together.
12+func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
13+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
14+ tb.ctx, tb.cancel = ctx, cancel
15+ for _, pane := range tb.panes {
16+ pane.ctx, pane.cancel = ctx, cancel
17+ }
18+ return tb
19+}
M internal/usecase/daemon/typed_connection_test.go +38 -88
@@ -156,14 +156,14 @@ func testDecodeClientFrame(frame wire.Frame) (protocol.ClientMessage, error) {
156 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)156 return wire.UnmarshalRouteAttentionSubscription(frame.Payload)
157 case wire.MsgSamePeerSwitchRequest:157 case wire.MsgSamePeerSwitchRequest:
158 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)158 return wire.UnmarshalSamePeerSwitchRequest(frame.Payload)
159- case wire.MsgParkedRouteRequest:
160- return wire.UnmarshalParkedRouteRequest(frame.Payload)
161 case wire.MsgNavigationInventoryRequest:159 case wire.MsgNavigationInventoryRequest:
162 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)160 return wire.UnmarshalNavigationInventoryRequest(frame.Payload)
163 case wire.MsgNavigationInventoryPublication:161 case wire.MsgNavigationInventoryPublication:
164 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)162 return wire.UnmarshalNavigationInventoryPublication(frame.Payload)
165 case wire.MsgNavigationInventoryFailure:163 case wire.MsgNavigationInventoryFailure:
166 return wire.UnmarshalNavigationInventoryFailure(frame.Payload)164 return wire.UnmarshalNavigationInventoryFailure(frame.Payload)
165+ case wire.MsgPickerPreviewRequest:
166+ return wire.UnmarshalPickerPreviewRequest(frame.Payload)
167 case wire.MsgRecentRouteSnapshot:167 case wire.MsgRecentRouteSnapshot:
168 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)168 return wire.UnmarshalRecentRouteSnapshot(frame.Payload)
169 case wire.MsgRouteNavigationFailure:169 case wire.MsgRouteNavigationFailure:
@@ -296,8 +296,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
296 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil296 return wire.Frame{Type: wire.MsgSessions, Payload: wire.MarshalSessions(m)}, nil
297 case protocol.CommandResult:297 case protocol.CommandResult:
298 return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil298 return wire.Frame{Type: wire.MsgCommandResult, Payload: wire.MarshalCommandResult(m)}, nil
299- case protocol.NavigationDirective:
300- return wire.Frame{Type: wire.MsgNavigationAction, Payload: wire.MarshalNavigationDirective(m)}, nil
301 case protocol.AttachTarget:299 case protocol.AttachTarget:
302 return wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(m)}, nil300 return wire.Frame{Type: wire.MsgAttachTarget, Payload: wire.MarshalAttachTarget(m)}, nil
303 case protocol.RemotePreview:301 case protocol.RemotePreview:
@@ -323,8 +321,6 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
323 case protocol.SamePeerSwitchFailure:321 case protocol.SamePeerSwitchFailure:
324 payload, err := wire.MarshalSamePeerSwitchFailure(m)322 payload, err := wire.MarshalSamePeerSwitchFailure(m)
325 return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err323 return wire.Frame{Type: wire.MsgSamePeerSwitchFailure, Payload: payload}, err
326- case protocol.ParkedRouteResponse:
327- return wire.Frame{Type: wire.MsgParkedRouteResponse, Payload: wire.MarshalParkedRouteResponse(m)}, nil
328 case protocol.NavigationInventoryResponse:324 case protocol.NavigationInventoryResponse:
329 payload := wire.MarshalNavigationInventoryResponse(m)325 payload := wire.MarshalNavigationInventoryResponse(m)
330 if payload == nil {326 if payload == nil {
@@ -343,6 +339,42 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
343 return wire.Frame{}, errors.New("test server connection: invalid inventory selection")339 return wire.Frame{}, errors.New("test server connection: invalid inventory selection")
344 }340 }
345 return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil341 return wire.Frame{Type: wire.MsgNavigationInventorySelection, Payload: payload}, nil
342+ case protocol.PickerSnapshot:
343+ payload := wire.MarshalPickerSnapshot(m)
344+ if payload == nil {
345+ return wire.Frame{}, errors.New("test server connection: invalid picker snapshot")
346+ }
347+ return wire.Frame{Type: wire.MsgPickerSnapshot, Payload: payload}, nil
348+ case protocol.PickerOffer:
349+ payload := wire.MarshalPickerOffer(m)
350+ if payload == nil {
351+ return wire.Frame{}, errors.New("test server connection: invalid picker offer")
352+ }
353+ return wire.Frame{Type: wire.MsgPickerOffer, Payload: payload}, nil
354+ case protocol.PickerClosed:
355+ payload := wire.MarshalPickerClosed(m)
356+ if payload == nil {
357+ return wire.Frame{}, errors.New("test server connection: invalid picker close")
358+ }
359+ return wire.Frame{Type: wire.MsgPickerClosedServer, Payload: payload}, nil
360+ case protocol.PickerResult:
361+ payload := wire.MarshalPickerResult(m)
362+ if payload == nil {
363+ return wire.Frame{}, errors.New("test server connection: invalid picker result")
364+ }
365+ return wire.Frame{Type: wire.MsgPickerResult, Payload: payload}, nil
366+ case protocol.PickerFailure:
367+ payload := wire.MarshalPickerFailure(m)
368+ if payload == nil {
369+ return wire.Frame{}, errors.New("test server connection: invalid picker failure")
370+ }
371+ return wire.Frame{Type: wire.MsgPickerFailure, Payload: payload}, nil
372+ case protocol.PickerPreview:
373+ payload := wire.MarshalPickerPreview(m)
374+ if payload == nil {
375+ return wire.Frame{}, errors.New("test server connection: invalid picker preview")
376+ }
377+ return wire.Frame{Type: wire.MsgPickerPreview, Payload: payload}, nil
346 default:378 default:
347 return wire.Frame{}, errors.New("test server connection: unsupported server message")379 return wire.Frame{}, errors.New("test server connection: unsupported server message")
348 }380 }
@@ -789,44 +821,6 @@ func (t *countingOutputTransport) Capabilities() protocol.ConnectionCapabilities
789 func (t *countingOutputTransport) LinkState() ports.LinkState { return testLinkState(t) }821 func (t *countingOutputTransport) LinkState() ports.LinkState { return testLinkState(t) }
790 func (t *countingOutputTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }822 func (t *countingOutputTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
791 823
792-func (t *parkedRouteExpiryTransport) ReceiveClient() (protocol.ClientMessage, error) {
793- return testReceiveClient(t)
794-}
795-func (t *parkedRouteExpiryTransport) SendServer(m protocol.ServerMessage) error {
796- return testSendServer(t, m)
797-}
798-func (t *parkedRouteExpiryTransport) SendServerAsync(m protocol.ServerMessage) error {
799- return testSendServerAsync(t, m)
800-}
801-func (t *parkedRouteExpiryTransport) SendServerSynchronous(m protocol.ServerMessage) error {
802- return testSendServerSynchronous(t, m)
803-}
804-func (t *parkedRouteExpiryTransport) Capabilities() protocol.ConnectionCapabilities {
805- return testServerCapabilities(t)
806-}
807-func (t *parkedRouteExpiryTransport) LinkState() ports.LinkState { return testLinkState(t) }
808-func (t *parkedRouteExpiryTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
809-
810-func (t *remotePickerSendErrorTransport) ReceiveClient() (protocol.ClientMessage, error) {
811- return testReceiveClient(t)
812-}
813-func (t *remotePickerSendErrorTransport) SendServer(m protocol.ServerMessage) error {
814- return testSendServer(t, m)
815-}
816-func (t *remotePickerSendErrorTransport) SendServerAsync(m protocol.ServerMessage) error {
817- return testSendServerAsync(t, m)
818-}
819-func (t *remotePickerSendErrorTransport) SendServerSynchronous(m protocol.ServerMessage) error {
820- return testSendServerSynchronous(t, m)
821-}
822-func (t *remotePickerSendErrorTransport) Capabilities() protocol.ConnectionCapabilities {
823- return testServerCapabilities(t)
824-}
825-func (t *remotePickerSendErrorTransport) LinkState() ports.LinkState { return testLinkState(t) }
826-func (t *remotePickerSendErrorTransport) LinkEvents() <-chan ports.LinkEvent {
827- return testLinkEvents(t)
828-}
829-
830 func (t failingOutputTransport) ReceiveClient() (protocol.ClientMessage, error) {824 func (t failingOutputTransport) ReceiveClient() (protocol.ClientMessage, error) {
831 return testReceiveClient(t)825 return testReceiveClient(t)
832 }826 }
@@ -861,24 +855,6 @@ func (t cacheFailTransport) Capabilities() protocol.ConnectionCapabilities {
861 func (t cacheFailTransport) LinkState() ports.LinkState { return testLinkState(t) }855 func (t cacheFailTransport) LinkState() ports.LinkState { return testLinkState(t) }
862 func (t cacheFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }856 func (t cacheFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
863 857
864-func (t parkedRouteFailTransport) ReceiveClient() (protocol.ClientMessage, error) {
865- return testReceiveClient(t)
866-}
867-func (t parkedRouteFailTransport) SendServer(m protocol.ServerMessage) error {
868- return testSendServer(t, m)
869-}
870-func (t parkedRouteFailTransport) SendServerAsync(m protocol.ServerMessage) error {
871- return testSendServerAsync(t, m)
872-}
873-func (t parkedRouteFailTransport) SendServerSynchronous(m protocol.ServerMessage) error {
874- return testSendServerSynchronous(t, m)
875-}
876-func (t parkedRouteFailTransport) Capabilities() protocol.ConnectionCapabilities {
877- return testServerCapabilities(t)
878-}
879-func (t parkedRouteFailTransport) LinkState() ports.LinkState { return testLinkState(t) }
880-func (t parkedRouteFailTransport) LinkEvents() <-chan ports.LinkEvent { return testLinkEvents(t) }
881-
882 func (t *blockedRenderReplacementTransport) SendOutput(o protocol.Output) error {858 func (t *blockedRenderReplacementTransport) SendOutput(o protocol.Output) error {
883 return testSendOutput(t, o)859 return testSendOutput(t, o)
884 }860 }
@@ -1071,24 +1047,6 @@ func (t *countingOutputTransport) SendOutputSynchronous(o protocol.Output) error
1071 return testSendOutputSynchronous(t, o)1047 return testSendOutputSynchronous(t, o)
1072 }1048 }
1073 1049
1074-func (t *parkedRouteExpiryTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) }
1075-func (t *parkedRouteExpiryTransport) SendOutputAsync(o protocol.Output) error {
1076- return testSendOutputAsync(t, o)
1077-}
1078-func (t *parkedRouteExpiryTransport) SendOutputSynchronous(o protocol.Output) error {
1079- return testSendOutputSynchronous(t, o)
1080-}
1081-
1082-func (t *remotePickerSendErrorTransport) SendOutput(o protocol.Output) error {
1083- return testSendOutput(t, o)
1084-}
1085-func (t *remotePickerSendErrorTransport) SendOutputAsync(o protocol.Output) error {
1086- return testSendOutputAsync(t, o)
1087-}
1088-func (t *remotePickerSendErrorTransport) SendOutputSynchronous(o protocol.Output) error {
1089- return testSendOutputSynchronous(t, o)
1090-}
1091-
1092 func (t failingOutputTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) }1050 func (t failingOutputTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) }
1093 func (t failingOutputTransport) SendOutputAsync(o protocol.Output) error {1051 func (t failingOutputTransport) SendOutputAsync(o protocol.Output) error {
1094 return testSendOutputAsync(t, o)1052 return testSendOutputAsync(t, o)
@@ -1104,11 +1062,3 @@ func (t cacheFailTransport) SendOutputAsync(o protocol.Output) error {
1104 func (t cacheFailTransport) SendOutputSynchronous(o protocol.Output) error {1062 func (t cacheFailTransport) SendOutputSynchronous(o protocol.Output) error {
1105 return testSendOutputSynchronous(t, o)1063 return testSendOutputSynchronous(t, o)
1106 }1064 }
1107-
1108-func (t parkedRouteFailTransport) SendOutput(o protocol.Output) error { return testSendOutput(t, o) }
1109-func (t parkedRouteFailTransport) SendOutputAsync(o protocol.Output) error {
1110- return testSendOutputAsync(t, o)
1111-}
1112-func (t parkedRouteFailTransport) SendOutputSynchronous(o protocol.Output) error {
1113- return testSendOutputSynchronous(t, o)
1114-}
M internal/usecase/daemon/ui_action_navigation_test.go +0 -45
@@ -12,7 +12,6 @@ func TestUIActionNavigationUsesAdmittedCause(t *testing.T) {
12 for _, actionID := range []uint64{0, 17} {12 for _, actionID := range []uint64{0, 17} {
13 for _, message := range []protocol.ServerMessage{13 for _, message := range []protocol.ServerMessage{
14 protocol.AttachTarget{CauseActionID: 999, Session: "destination", Intent: protocol.IntentAttach},14 protocol.AttachTarget{CauseActionID: 999, Session: "destination", Intent: protocol.IntentAttach},
15- protocol.NavigationDirective{CauseActionID: 999, Action: protocol.NavigationBack},
16 protocol.RouteNavigationAction{CauseActionID: 999, SnapshotGeneration: 1, Key: 2, Generation: 3},15 protocol.RouteNavigationAction{CauseActionID: 999, SnapshotGeneration: 1, Key: 2, Generation: 3},
17 protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"},16 protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"},
18 } {17 } {
@@ -41,8 +40,6 @@ func testNavigationName(message protocol.ServerMessage, actionID uint64) string
41 switch message.(type) {40 switch message.(type) {
42 case protocol.AttachTarget:41 case protocol.AttachTarget:
43 return name + "attach"42 return name + "attach"
44- case protocol.NavigationDirective:
45- return name + "directive"
46 case protocol.RouteNavigationAction:43 case protocol.RouteNavigationAction:
47 return name + "recent"44 return name + "recent"
48 default:45 default:
@@ -57,10 +54,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 {
57 message, err := wire.UnmarshalAttachTarget(frame.Payload)54 message, err := wire.UnmarshalAttachTarget(frame.Payload)
58 require.NoError(t, err)55 require.NoError(t, err)
59 return message.CauseActionID56 return message.CauseActionID
60- case wire.MsgNavigationAction:
61- message, err := wire.UnmarshalNavigationDirective(frame.Payload)
62- require.NoError(t, err)
63- return message.CauseActionID
64 case wire.MsgNavigateRecentRoute:57 case wire.MsgNavigateRecentRoute:
65 message, err := wire.UnmarshalRouteNavigationAction(frame.Payload)58 message, err := wire.UnmarshalRouteNavigationAction(frame.Payload)
66 require.NoError(t, err)59 require.NoError(t, err)
@@ -75,44 +68,6 @@ func testNavigationCause(t *testing.T, frame wire.Frame) uint64 {
75 }68 }
76 }69 }
77 70
78-func TestUIActionDelayedKeyRetainsOriginalCause(t *testing.T) {
79- for _, revoked := range []bool{false, true} {
80- t.Run(map[bool]string{false: "later action", true: "revoked lease"}[revoked], func(t *testing.T) {
81- d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
82- rc := d.attachCoordinator(sess, nil, ac, true)
83- t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
84- capability := captureAttachmentCapability(sess, ac, ac.transport())
85- effect, ok := ac.beginAttachmentEffect(capability)
86- require.True(t, ok)
87- effect.uiActionID = 7
88- handler := daemonKeyHandler{d: d, ac: ac, effect: effect}
89- effect.End()
90- later, ok := ac.beginAttachmentEffect(capability)
91- require.True(t, ok)
92- later.uiActionID = 99
93- later.End()
94- if revoked {
95- rc.mu.Lock()
96- rc.rebindAttachmentWithReadinessLocked(ac, true)
97- rc.mu.Unlock()
98- }
99- current, delayed, owned := handler.acquireAttachmentEffect()
100- if revoked {
101- require.Nil(t, current)
102- require.Nil(t, delayed)
103- require.Empty(t, sends)
104- return
105- }
106- require.Same(t, sess, current)
107- require.True(t, owned)
108- t.Cleanup(delayed.End)
109- require.Equal(t, uint64(7), delayed.uiActionID)
110- require.NoError(t, d.sendNavigationActionForAttachment(delayed, protocol.NavigationBack))
111- require.Equal(t, uint64(7), testNavigationCause(t, awaitTestValue(t, sends, "delayed navigation was not sent")))
112- })
113- }
114-}
115-
116 func TestUIActionInputPaletteSubmissionCarriesItsOwnCause(t *testing.T) {71 func TestUIActionInputPaletteSubmissionCarriesItsOwnCause(t *testing.T) {
117 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)72 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
118 rc := d.attachCoordinator(sess, nil, ac, true)73 rc := d.attachCoordinator(sess, nil, ac, true)
A internal/usecase/picker/modal.go +30 -0
@@ -0,0 +1,30 @@
1+package picker
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+)
7+
8+// This file owns the picker's presentation geometry. The serving daemon and the
9+// presenting client both need it: the daemon sizes the preview it captures from
10+// the same box the client draws, so the viewport always fits its pane.
11+
12+// Modal is the picker's floating presentation: a centred box over the session,
13+// never a full-screen takeover. Both sides resolve it against the terminal size
14+// they are working with.
15+var Modal = ui.Modal{
16+ WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8,
17+ Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{},
18+}
19+
20+// PreviewRect returns the preview pane inside the modal for one terminal size.
21+// An empty rectangle means this terminal has no room for a preview.
22+func PreviewRect(terminal domain.Size) domain.Rect {
23+ presentation := Modal.Resolve(terminal)
24+ return ChooseGeometry(Size(presentation.Inner)).Preview
25+}
26+
27+// Size converts a rectangle to the dimensions a frame is built from.
28+func Size(rect domain.Rect) domain.Size {
29+ return domain.Size{Cols: rect.Width, Rows: rect.Height}
30+}
M internal/usecase/picker/model.go +314 -504 collapsed to keep the page light
M internal/usecase/picker/model_test.go +262 -968 collapsed to keep the page light
D internal/usecase/picker/remote_parity_test.go +0 -82 collapsed to keep the page light
M internal/usecase/picker/search.go +48 -71 collapsed to keep the page light
A internal/usecase/picker/target.go +23 -0
@@ -0,0 +1,23 @@
1+package picker
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+// Target is the resolved navigation destination behind one opaque picker key.
6+// It stays source-private: only the owning daemon resolves keys to targets and
7+// commits them; a presenting client never sees one.
8+type Target struct {
9+ Session domain.SessionID
10+ Incarnation domain.IncarnationID
11+ Name string
12+ RemoteKey *domain.RemoteSessionKey
13+ RemoteTarget *domain.RemoteSessionTarget
14+ RemoteHost string
15+ UnavailableReason string
16+ TabID domain.TabStableID
17+ TabIndex int
18+ Stopped bool
19+ // ExpectedCreatedAt optionally pins this target to a particular named
20+ // session lifecycle. Callers that obtain a snapshot outside the daemon use
21+ // it to reject a same-name replacement at commit time.
22+ ExpectedCreatedAt *int64
23+}
M internal/usecase/remotes/monitor.go +5 -1
@@ -341,9 +341,13 @@ func (m *Monitor) applyResult(now time.Time, state *serviceState, result ports.R
341 return341 return
342 }342 }
343 state.registryDone = true343 state.registryDone = true
344+ wasInitialized := state.registryInit
344 state.registryInit = true345 state.registryInit = true
345 state.registryError = nil346 state.registryError = nil
346- if m.reconcileRegistrations(now, state, result.Registrations) {347+ if m.reconcileRegistrations(now, state, result.Registrations) || !wasInitialized {
348+ // The first successful registry read is itself a publication: an
349+ // empty registry is a known state, and a monitor that never
350+ // publishes it leaves every reader reporting "checking remotes".
347 state.dirty = true351 state.dirty = true
348 }352 }
349 if expedited {353 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 collapsed to keep the page light
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