detached 9cec0d8

feat(ui): add headless UI driver and observation controls (#260)

* build(deps): pin vev-vt terminal fidelity

* build(tools): update mockery to v3.7.4

* feat(ui): add terminal snapshot primitive

* feat(protocol): add UI synchronization messages

* feat(protocol): carry view context in output

* fix(protocol): bound semantic output in version 41

* feat(daemon): prepare semantic view publications

* feat(protocol): correlate navigation with UI actions

* feat(daemon): publish captured semantic views with rendering

* fix(daemon): retry EOF teardown after attachment churn

* test(daemon): bound allocation checks and fixture setup

* feat(daemon): fence UI actions at captured render publications

* feat(daemon): preserve UI action causes through navigation

* feat(client): apply captured semantic view publications

* test(dgram): synchronize simulator with coalesced ACK deadlines

* feat(protocol): require context on state-bearing output

* feat(client): expose safe scanner batch boundaries

* feat(client): admit labeled input at clean scanner boundaries

* feat(client): track applied UI actions and bounded predicate waits

* feat(client): follow UI action causes through foreground handoffs

* test(client): validate remote and parked UI action handoffs

* feat(ui): add JSONL driver composition

* feat(ui): isolate driver endpoint environments

* test(ui): cover interactive observation paths

* fix(ui): harden private socket cleanup

* test(remote): verify isolated launch quoting

* refactor(term): reuse visual sequence helpers

* fix(ui): publish observed geometry changes

* fix(client): complete correlated route failures

* fix(ui): bound response fallback allocation

* fix(ui): retain action admission after timeout

* fix(ui): address senior review findings

* fix(ui): hide headless driver entry point

* fix(ui): harden reviewed driver paths

* fix(ui): use portable socket path limits

* fix(ui): make macOS driver paths portable

* test(ui): stabilize macOS render checks
commit
9cec0d8356c5fbdfd18378f3b44250557df4452a
author
bnema · · 2026-09-07 06:52 UTC
committer
GitHub · · 2026-09-07 06:52 UTC
parent
845b093
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
146 files · +10617 -454
M AGENTS.md +1 -1
@@ -16,7 +16,7 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo
16 ```16 ```
17 17
18 - Format with **goimports**.18 - Format with **goimports**.
19-- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.7.1`.19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.7.4`.
20 - Use Mockery v3 consistently across Go projects.20 - Use Mockery v3 consistently across Go projects.
21 - `make test` runs `go test ./... -race`.21 - `make test` runs `go test ./... -race`.
22 - `make lint` checks `goimports -l`, then `go vet`.22 - `make lint` checks `goimports -l`, then `go vet`.
M Makefile +2 -2
@@ -19,8 +19,8 @@ lint:
19 19
20 mocks:20 mocks:
21 @version="$$(mockery version 2>/dev/null || true)"; \21 @version="$$(mockery version 2>/dev/null || true)"; \
22- if [ "$$version" != "v3.7.1" ]; then \22+ if [ "$$version" != "v3.7.4" ]; then \
23- echo "mockery v3.7.1 required; install with: go install github.com/vektra/mockery/v3@v3.7.1" >&2; \23+ echo "mockery v3.7.4 required; install with: go install github.com/vektra/mockery/v3@v3.7.4" >&2; \
24 exit 1; \24 exit 1; \
25 fi25 fi
26 mockery26 mockery
M README.md +8 -0
@@ -36,6 +36,9 @@ vev ls [<host>|--all] list sessions
36 vev host add|rm <host> manage remote hosts36 vev host add|rm <host> manage remote hosts
37 vev host list list remote hosts37 vev host list list remote hosts
38 vev kill <name>|--all kill sessions38 vev kill <name>|--all kill sessions
39+vev --ui-driver [options] drive a headless attachment over JSONL
40+vev --ui-observe ... expose passive observation for an attach
41+vev --ui-control ... expose observation and input control
39 ```42 ```
40 43
41 The daemon starts on first use and exits after the last session. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md).44 The daemon starts on first use and exits after the last session. Numbered sessions survive detach; named sessions also survive daemon restarts. Exiting the final shell removes that session and returns to the most recently used session; with no previous session, vev exits. See [durable-session recovery](docs/durable-session-recovery.md).
@@ -79,6 +82,10 @@ vev attach user@host[:session]
79 82
80 SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details.83 SSH bootstraps an authenticated direct UDP connection that resumes after sleep or network changes. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. The remote host needs vev installed; see [remote resilience](docs/remote-resilience.md) for firewall, transport, and host-list details.
81 84
85+## UI driver
86+
87+`vev --ui-driver` provides bounded capture, key/text input, and deterministic waits for a headless attachment. Existing clients stay unchanged unless `--ui-observe` or `--ui-control` is explicitly enabled. See [UI driver](docs/ui-driver.md) for the JSONL contract, private socket bridge, completion semantics, and isolated endpoint configuration.
88+
82 ## Scripting89 ## Scripting
83 90
84 `vev cmd` controls a running daemon without starting one:91 `vev cmd` controls a running daemon without starting one:
@@ -97,6 +104,7 @@ Use `vev cmd --help` for commands and targeting options.
97 - [Terminal compatibility](docs/terminal.md)104 - [Terminal compatibility](docs/terminal.md)
98 - [Remote resilience](docs/remote-resilience.md)105 - [Remote resilience](docs/remote-resilience.md)
99 - [Durable session recovery](docs/durable-session-recovery.md)106 - [Durable session recovery](docs/durable-session-recovery.md)
107+- [UI driver](docs/ui-driver.md): headless capture/control and opt-in interactive observation
100 - [Performance](docs/performance.md)108 - [Performance](docs/performance.md)
101 109
102 ## Development110 ## Development
M docs/architecture.md +11 -3
@@ -11,8 +11,10 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
11 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.11 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
12 - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.12 - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.
13 - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification.13 - `internal/adapters/sessionwire`: translates between typed session connections and raw wire transports, including direction checks and decode-failure classification.
14-- `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, persistence-facing, and observability implementations.14+- `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment.
15-- `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, and injects typed ports into use cases.15+- `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled.
16+- `internal/adapters`: IPC, UDP, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations.
17+- `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration.
16 - `pkg`: reusable packages that never import `internal`.18 - `pkg`: reusable packages that never import `internal`.
17 19
18 ## Dependency direction20 ## Dependency direction
@@ -29,6 +31,12 @@ main → app → usecase → ports
29 31
30 `internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. The existing `adapters/snapshot → usecase/snapshot` dependency is an explicit exception until the snapshot codec has its own owner. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`.32 `internal/app` may import every layer to compose the process. Adapters depend inward on ports, protocol, and domain. The existing `adapters/snapshot → usecase/snapshot` dependency is an explicit exception until the snapshot codec has its own owner. Production dependency rules and the separate test-import policy are executable in `boundary_test.go`.
31 33
34+## UI observation and control
35+
36+Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. It does not bypass the palette, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
37+
38+Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal.
39+
32 ## Session composition40 ## Session composition
33 41
34 ```text42 ```text
@@ -48,4 +56,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
48 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.56 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
49 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.57 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
50 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.58 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
51-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`).59+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `41`, including UI view context and processing-fence messages).
A docs/ui-driver.md +138 -0
@@ -0,0 +1,138 @@
1+# UI driver
2+
3+`vev --ui-driver` exposes one opt-in attachment as a bounded JSON Lines stream. It is intentionally a hidden headless entry point for tests and local automation that need the rendered terminal, not a second terminal implementation.
4+
5+## Headless attachment
6+
7+Start a new numbered session with the default 80x24 viewport:
8+
9+```sh
10+vev --ui-driver
11+```
12+
13+The first line on stdout is the attachment discovery response. It is emitted only after the client has applied and published its initial full view:
14+
15+```json
16+{"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":1,"control":true,"status":"attached"}}
17+```
18+
19+The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`:
20+
21+```json
22+{"version":1,"id":1,"op":"capture","attachment":"<opaque-handle>","format":"both"}
23+```
24+
25+`format` is `text` (the default), `cells`, or `both`. Text contains exactly one newline-separated string per row, including trailing blank rows. Cells are row-major and contain `text`, `width`, `continuation`, and the rendered style. Captures also contain the revision, coherent route/session/focus context, geometry, and cursor.
26+
27+Input targets the exact current `generation`:
28+
29+```json
30+{"version":1,"id":2,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Alt+Space"]}
31+{"version":1,"id":3,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Escape"]}
32+{"version":1,"id":4,"op":"text","attachment":"<opaque-handle>","generation":1,"text":"printf 'driver ok'"}
33+{"version":1,"id":5,"op":"keys","attachment":"<opaque-handle>","generation":1,"keys":["Enter"]}
34+{"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":5,"expect":{"text_contains":"driver ok"}}
35+```
36+
37+`keys` accepts only the documented finite grammar: `Enter`, `Escape`, `Tab`, `Backspace`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`, `PageDown`, `Space`, one printable ASCII character, `Ctrl+` with the supported ASCII control set, and `Alt+` followed by one supported unmodified ASCII character. Cursor navigation follows the terminal's application-cursor mode. Raw escape notation, keypad keys, combined modifiers, and unknown names are rejected.
38+
39+`text` is literal printable UTF-8. It does not mean paste and cannot contain C0/C1 control characters; use an explicit key for Enter, Tab, or Escape.
40+
41+## Completion and waits
42+
43+An accepted action is sent through the normal client input owner. A `processed` action means that its input batch reached the normal dispatch boundary and its required render/publication boundary. It does not mean that a child process has echoed the input or completed a command. A local palette action uses the client event-loop boundary instead of a fabricated daemon fence.
44+
45+Navigation actions remain pending until the existing handoff reaches a validated, written, and published full destination view. If the handoff or transport cannot be attributed safely, the action reports `outcome_unknown`; it is never replayed into a new generation. A failed route reports `navigation_failed`.
46+
47+A request timeout only ends that request. If input was already accepted, the response has `accepted:true` and an `action_id`; the input is not rolled back. Use `wait` with `after_action` to retrieve the later result. An evicted action reports `action_expired`.
48+
49+`wait` requires at least one predicate. All supplied predicates are conjunctive:
50+
51+```json
52+{"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}}
53+```
54+
55+Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`attached`, `transitioning`, `reconnecting`, or `detached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text.
56+
57+## Limits and errors
58+
59+- Requests are at most 64 KiB. A complete final JSON object without a newline is accepted; truncated JSON is not.
60+- Input is at most 16 KiB and a `keys` request has 1–256 tokens.
61+- Headless viewports are at most 512 columns by 256 rows.
62+- The default and maximum request timeout are 5 seconds and 30 seconds.
63+- Each attachment allows at most four socket connections, one admitted action across all controllers, four active waits, and 64 retained action records.
64+- Each connection has one response being written and at most two queued responses. A slow reader is closed after the bounded write deadline or queue overrun.
65+- Serialized response memory is bounded to 128 MiB per attachment. An oversized capture returns `capture_too_large`; it never resizes an interactive terminal.
66+
67+Errors are structured and sanitized. Stable codes include `invalid_request`, `unsupported_version`, `permission_denied`, `stale_attachment`, `unavailable`, `timeout`, `outcome_unknown`, `capture_too_large`, `action_expired`, `input_busy`, `busy`, `navigation_failed`, and `endpoint_not_configured`. Error messages contain only the code; screen contents, credentials, endpoint internals, and environment values are not logged or returned.
68+
69+EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions on an existing endpoint. For an explicit launch configuration, the app performs a separate owned-endpoint teardown after driver access closes; it stops and removes only roots this invocation created.
70+
71+## Explicit launch configuration
72+
73+Without `--launch-config`, normal local daemon startup and inherited client environment policy are unchanged. The option is the only way to request an explicit isolated endpoint:
74+
75+```sh
76+vev --ui-driver --session test --cols 100 --rows 30 \
77+ --launch-config /absolute/path/launch.json
78+```
79+
80+The configuration file must be owned by the current user, mode `0600`, valid UTF-8 JSON, and at most 64 KiB:
81+
82+```json
83+{
84+ "version": 1,
85+ "local": {
86+ "binary": "/absolute/path/vev",
87+ "root": "/absolute/path/new-local-root",
88+ "env": {
89+ "HOME": "/home/test",
90+ "PATH": "/usr/bin:/bin",
91+ "SHELL": "/bin/sh"
92+ }
93+ },
94+ "remotes": [
95+ {
96+ "endpoint": "user@example.com",
97+ "binary": "/absolute/path/vev",
98+ "root": "/absolute/path/new-remote-root",
99+ "env": {
100+ "HOME": "/home/test",
101+ "PATH": "/usr/bin:/bin",
102+ "SHELL": "/bin/sh"
103+ }
104+ }
105+ ]
106+}
107+```
108+
109+`local` may be omitted only for a direct `--remote` start. `remotes` defaults to an empty list. Binary and root paths must be absolute. Environment maps are complete child environments, not merges with the invoking process. `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_RUNTIME_DIR`, `VEV_ENV`, and `VEV_ENV_ROOT` are reserved and supplied by vev; they cannot appear in the file. Values are passed as argument data through the existing process/SSH builders, never by shell-concatenating untrusted strings.
110+
111+A configured root must not exist before the invocation creates it. Local roots and remotely created roots use private derived config, state, runtime, and temporary directories; a remote root is reused only by later connections carrying the same invocation-owned token. A launch-config invocation refuses to fall back to an unlisted remote endpoint, including during catalogue and preview discovery. Directory isolation is not a process, filesystem, or network sandbox; environment files and binaries remain sensitive.
112+
113+## Existing interactive clients
114+
115+Observation and control are disabled by default. Enable them before an attach command:
116+
117+```sh
118+vev --ui-observe attach work
119+vev --ui-control new work
120+```
121+
122+`--ui-control` implies observation. The client prints one socket path to stderr, or uses the supplied private path:
123+
124+```sh
125+vev --ui-observe --ui-socket /absolute/private/ui.sock attach work
126+```
127+
128+The socket parent is private (`0700`) and the socket is private (`0600`). On Linux, the server also requires the connecting peer's UID to match the client UID. A path collision is rejected and never unlinks an existing file. Read-only observation rejects `keys` and `text` before they reach the input owner.
129+
130+The bridge form connects JSONL stdio to an already-running opted-in client; it does not create another attachment:
131+
132+```sh
133+vev --ui-driver --socket /absolute/private/ui.sock
134+```
135+
136+The physical terminal remains authoritative for geometry and terminal query responses. Mirroring observes bytes at the serialized terminal writer and publishes only after a successful physical flush. Capture and wait never write to the terminal, request a redraw, change focus, claim PTY geometry, send Resize, or acknowledge protocol output independently. If mirroring fails, capture becomes unavailable while the interactive client continues running. Disconnecting an observer leaves the interactive attachment and its shells running.
137+
138+The driver intentionally does not provide image capture, mouse/paste/resize commands, subscriptions, scenarios, replay, automatic artifact export, public TCP access, or sandbox management.
M go.mod +2 -1
@@ -3,8 +3,9 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.5.16+ github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb
7 github.com/stretchr/testify v1.11.17 github.com/stretchr/testify v1.11.1
8+ golang.org/x/sys v0.42.0
8 )9 )
9 10
10 require (11 require (
M go.sum +4 -2 generated file: collapsed
A internal/adapters/dgram/ack_schedule_test.go +75 -0
@@ -0,0 +1,75 @@
1+package dgram
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestACKScheduleCoalescedWakeCompletesEmptyDeadline(t *testing.T) {
13+ clock := portsmocks.NewMockClock(t)
14+ timer := portsmocks.NewMockTimer(t)
15+ deadline := make(chan time.Time, 1)
16+ completed := make(chan bool, 2)
17+ transport := &Transport{
18+ clock: clock, done: make(chan struct{}),
19+ ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1),
20+ afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched },
21+ }
22+ clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer {
23+ // A second record's wake and the first deadline become ready together.
24+ // The deadline consumes the cumulative ACK, leaving a harmless queued
25+ // wake whose next deadline has nothing left to dispatch.
26+ transport.queueACK(2)
27+ deadline <- time.Time{}
28+ return timer
29+ }).Once()
30+ timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Twice()
31+ timer.EXPECT().Reset(maxACKDelay).Run(func(time.Duration) { deadline <- time.Time{} }).Return(false).Once()
32+ timer.EXPECT().Stop().Return(false).Once()
33+ transport.queueACK(1)
34+ done := make(chan struct{})
35+ go func() { defer close(done); transport.ackScheduleLoop() }()
36+ t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") })
37+ require.True(t, awaitResult(t, completed, "cumulative ACK dispatch"))
38+ require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK"))
39+ require.False(t, awaitResult(t, completed, "coalesced empty ACK deadline completion"), "a timer deadline need not dispatch a second ACK")
40+ require.Empty(t, transport.ackSend)
41+}
42+
43+func TestACKScheduleCoalescedWakeDoesNotArmAnotherDeadline(t *testing.T) {
44+ clock := portsmocks.NewMockClock(t)
45+ timer := portsmocks.NewMockTimer(t)
46+ deadline := make(chan time.Time, 1)
47+ armed := make(chan bool, 2)
48+ completed := make(chan bool, 2)
49+ transport := &Transport{
50+ clock: clock, done: make(chan struct{}),
51+ ackWake: make(chan struct{}, 1), ackSend: make(chan uint64, 1),
52+ afterACKScheduled: func(created bool) {
53+ armed <- created
54+ if !created {
55+ deadline <- time.Time{}
56+ }
57+ },
58+ afterACKDispatchAttempt: func(dispatched bool) { completed <- dispatched },
59+ }
60+ clock.EXPECT().NewTimer(maxACKDelay).RunAndReturn(func(time.Duration) ports.Timer {
61+ transport.queueACK(2)
62+ return timer
63+ }).Once()
64+ timer.EXPECT().C().Return((<-chan time.Time)(deadline)).Once()
65+ timer.EXPECT().Stop().Return(false).Once()
66+ transport.queueACK(1)
67+ done := make(chan struct{})
68+ go func() { defer close(done); transport.ackScheduleLoop() }()
69+ t.Cleanup(func() { close(transport.done); awaitSignal(t, done, "ACK scheduler shutdown") })
70+ require.True(t, awaitResult(t, armed, "first ACK deadline"))
71+ require.False(t, awaitResult(t, armed, "coalesced ACK scheduling"))
72+ require.True(t, awaitResult(t, completed, "one cumulative ACK dispatch"))
73+ require.Equal(t, uint64(2), awaitResult(t, transport.ackSend, "cumulative ACK"))
74+ require.Empty(t, completed)
75+}
M internal/adapters/dgram/control.go +21 -16
@@ -99,7 +99,8 @@ func (t *Transport) ackScheduleLoop() {
99 }99 }
100 }()100 }()
101 scheduleACK := func() {101 scheduleACK := func() {
102- if ackTimerC == nil {102+ armed := ackTimerC == nil
103+ if armed {
103 if ackTimer == nil {104 if ackTimer == nil {
104 ackTimer = t.clock.NewTimer(maxACKDelay)105 ackTimer = t.clock.NewTimer(maxACKDelay)
105 } else {106 } else {
@@ -111,26 +112,30 @@ func (t *Transport) ackScheduleLoop() {
111 hook := t.afterACKScheduled112 hook := t.afterACKScheduled
112 t.mu.Unlock()113 t.mu.Unlock()
113 if hook != nil {114 if hook != nil {
114- hook()115+ hook(armed)
115 }116 }
116 }117 }
117 dispatchACK := func() {118 dispatchACK := func() {
118 seq, ok := t.takeACK()119 seq, ok := t.takeACK()
119- if !ok {120+ dispatched := false
120- return121+ if ok {
121- }122+ select {
122- select {123+ case t.ackSend <- seq:
123- case t.ackSend <- seq:124+ dispatched = true
124- t.mu.Lock()125+ default:
125- hook := t.afterACKDispatched126+ // Preserve the cumulative maximum until the bounded ACK sender
126- t.mu.Unlock()127+ // has capacity again.
127- if hook != nil {128+ t.queueACK(seq)
128- hook()
129 }129 }
130- default:130+ }
131- // Preserve the cumulative maximum until the bounded ACK sender has131+ // A queued wake can outlive the deadline that already consumed its
132- // capacity again.132+ // cumulative ACK. Signal completion even when this attempt is empty;
133- t.queueACK(seq)133+ // manual-clock tests must not wait for a nonexistent socket write.
134+ t.mu.Lock()
135+ hook := t.afterACKDispatchAttempt
136+ t.mu.Unlock()
137+ if hook != nil {
138+ hook(dispatched)
134 }139 }
135 }140 }
136 141
M internal/adapters/dgram/network_sim_test.go +20 -13
@@ -400,8 +400,8 @@ func TestTransportFloodClassification(t *testing.T) {
400 primerWritten := make(chan struct{}, 1)400 primerWritten := make(chan struct{}, 1)
401 primerAcknowledged := make(chan struct{}, 1)401 primerAcknowledged := make(chan struct{}, 1)
402 ackWakeAccepted := make(chan struct{}, 128)402 ackWakeAccepted := make(chan struct{}, 128)
403- ackScheduled := make(chan struct{}, 128)403+ ackScheduled := make(chan bool, 128)
404- ackDispatched := make(chan struct{}, 128)404+ ackDispatched := make(chan bool, 128)
405 ackWritten := make(chan struct{}, 128)405 ackWritten := make(chan struct{}, 128)
406 aPC.replaceAfterWrite(func() {406 aPC.replaceAfterWrite(func() {
407 select {407 select {
@@ -441,20 +441,24 @@ func TestTransportFloodClassification(t *testing.T) {
441 default:441 default:
442 }442 }
443 }443 }
444- b.afterACKScheduled = func() {444+ b.afterACKScheduled = func(armed bool) {
445 select {445 select {
446- case ackScheduled <- struct{}{}:446+ case ackScheduled <- armed:
447 default:447 default:
448 }448 }
449 }449 }
450- b.afterACKDispatched = func() {450+ b.afterACKDispatchAttempt = func(dispatched bool) {
451 select {451 select {
452- case ackDispatched <- struct{}{}:452+ case ackDispatched <- dispatched:
453 default:453 default:
454 }454 }
455 }455 }
456 b.mu.Unlock()456 b.mu.Unlock()
457- primer := mustMarshalOutput(protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")})457+ // The RTT primer must be a single transport record fragment: the
458+ // following barrier waits for its record ACK, not partial packet contact.
459+ // Use ordered side-effect bytes so semantic state metadata does not turn
460+ // this setup step into the fragmented flood exercised below.
461+ primer := mustMarshalOutput(protocol.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("primer")})
458 if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: primer}); err != nil {462 if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: primer}); err != nil {
459 t.Fatal(err)463 t.Fatal(err)
460 }464 }
@@ -464,13 +468,16 @@ func TestTransportFloodClassification(t *testing.T) {
464 for range 40 {468 for range 40 {
465 clk.advance(25 * time.Millisecond)469 clk.advance(25 * time.Millisecond)
466 if awaitingPrimerACK {470 if awaitingPrimerACK {
471+ if !awaitResult(t, ackDispatched, "primer ACK dispatch") {
472+ t.Fatal("primer deadline did not dispatch its ACK")
473+ }
467 awaitSignal(t, primerAcknowledged, "primer ACK write")474 awaitSignal(t, primerAcknowledged, "primer ACK write")
475+ awaitSignal(t, ackWritten, "primer ACK write completion")
468 awaitingPrimerACK = false476 awaitingPrimerACK = false
469 }477 }
470 if flushAndAwait(t, forward, bPC, processed) > 0 {478 if flushAndAwait(t, forward, bPC, processed) > 0 {
471 awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake")479 awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake")
472- awaitSignal(t, ackScheduled, "primer ACK scheduling")480+ awaitingPrimerACK = awaitResult(t, ackScheduled, "primer ACK scheduling")
473- awaitingPrimerACK = true
474 }481 }
475 flushAndAwait(t, reverse, aPC, processed)482 flushAndAwait(t, reverse, aPC, processed)
476 a.mu.Lock()483 a.mu.Lock()
@@ -582,15 +589,15 @@ func TestTransportFloodClassification(t *testing.T) {
582 }589 }
583 }590 }
584 if awaitingACKWrite {591 if awaitingACKWrite {
585- awaitSignal(t, ackDispatched, "cumulative ACK dispatch")592+ if awaitResult(t, ackDispatched, "cumulative ACK dispatch attempt") {
586- awaitSignal(t, ackWritten, "cumulative ACK write")593+ awaitSignal(t, ackWritten, "cumulative ACK write")
594+ }
587 awaitingACKWrite = false595 awaitingACKWrite = false
588 }596 }
589 if flushAndAwait(t, forward, bPC, processed) > 0 {597 if flushAndAwait(t, forward, bPC, processed) > 0 {
590 select {598 select {
591 case <-ackWakeAccepted:599 case <-ackWakeAccepted:
592- awaitSignal(t, ackScheduled, "cumulative ACK scheduling")600+ awaitingACKWrite = awaitResult(t, ackScheduled, "cumulative ACK scheduling")
593- awaitingACKWrite = true
594 default:601 default:
595 }602 }
596 }603 }
M internal/adapters/dgram/ssh_dialer.go +18 -1
@@ -107,6 +107,13 @@ type RemoteDialer struct {
107 ProbeTimeout time.Duration107 ProbeTimeout time.Duration
108 Log *slog.Logger108 Log *slog.Logger
109 RuntimeObserver ports.SerializedRuntimeObserver109 RuntimeObserver ports.SerializedRuntimeObserver
110+ // BootstrapBinary and BootstrapEnvironment are set only by an explicit
111+ // launch configuration. Empty values retain the normal remote `vev`
112+ // bootstrap command.
113+ BootstrapBinary string
114+ BootstrapRoot string
115+ BootstrapOwnerToken string
116+ BootstrapEnvironment []string
110 }117 }
111 118
112 func NewRemoteDialer(target, _ string) RemoteDialer {119 func NewRemoteDialer(target, _ string) RemoteDialer {
@@ -117,6 +124,16 @@ func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer
117 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}124 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
118 }125 }
119 126
127+func (d RemoteDialer) bootstrapProcess(ctx context.Context, stderr io.Writer) bootstrapProcess {
128+ if d.BootstrapBinary == "" {
129+ return startUDPBootstrap(ctx, d.Target, stderr)
130+ }
131+ spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(d.Target, d.BootstrapRoot, d.BootstrapOwnerToken, d.BootstrapBinary, d.BootstrapEnvironment, "_udp-bootstrap")
132+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
133+ cmd.Stderr = stderr
134+ return execBootstrapProcess{cmd: cmd}
135+}
136+
120 func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {137 func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
121 if err := ctx.Err(); err != nil {138 if err := ctx.Err(); err != nil {
122 return nil, err139 return nil, err
@@ -133,7 +150,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
133 defer bootstrapCancel()150 defer bootstrapCancel()
134 151
135 var stderr limitedBuffer152 var stderr limitedBuffer
136- proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)153+ proc := d.bootstrapProcess(bootstrapCtx, &stderr)
137 stdout, err := proc.StdoutPipe()154 stdout, err := proc.StdoutPipe()
138 if err != nil {155 if err != nil {
139 return nil, udpUnavailable("bootstrap stdout", err, &stderr)156 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
M internal/adapters/dgram/transport.go +3 -3
@@ -248,9 +248,9 @@ type Transport struct {
248 // authenticated packet has been fully processed. It is read under mu.248 // authenticated packet has been fully processed. It is read under mu.
249 afterPacketProcessed func()249 afterPacketProcessed func()
250 // ACK hooks are test-only and observers must be non-blocking.250 // ACK hooks are test-only and observers must be non-blocking.
251- afterACKWakeAccepted func()251+ afterACKWakeAccepted func()
252- afterACKScheduled func()252+ afterACKScheduled func(armed bool)
253- afterACKDispatched func()253+ afterACKDispatchAttempt func(dispatched bool)
254 // outboundMu orders first transmissions and paced batches. Retransmits use254 // outboundMu orders first transmissions and paced batches. Retransmits use
255 // writeMu directly because they do not establish new sequence order.255 // writeMu directly because they do not establish new sequence order.
256 outboundMu sync.Mutex256 outboundMu sync.Mutex
M internal/adapters/dgram/wire_test_helpers_test.go +8 -0
@@ -1,11 +1,19 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "github.com/bnema/vev/internal/domain"
4 "github.com/bnema/vev/internal/protocol"5 "github.com/bnema/vev/internal/protocol"
5 "github.com/bnema/vev/internal/protocol/wire"6 "github.com/bnema/vev/internal/protocol/wire"
6 )7 )
7 8
8 func mustMarshalOutput(m protocol.Output) []byte {9 func mustMarshalOutput(m protocol.Output) []byte {
10+ if m.New != 0 && m.Context == nil {
11+ m.Context = &protocol.ViewContext{
12+ Publication: m.New,
13+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "transport-test"}},
14+ TabID: "tab-1", FocusedPaneID: "pane-1",
15+ }
16+ }
9 payload, err := wire.MarshalOutput(m)17 payload, err := wire.MarshalOutput(m)
10 if err != nil {18 if err != nil {
11 panic(err)19 panic(err)
M internal/adapters/remote/remote_factory.go +41 -2
@@ -2,6 +2,7 @@ package remote
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "log/slog"7 "log/slog"
7 8
@@ -24,6 +25,15 @@ type DialerFactory struct {
24 observer ports.SerializedRuntimeObserver25 observer ports.SerializedRuntimeObserver
25 }26 }
26 27
28+// EndpointLaunch contains the absolute remote executable and complete child
29+// environment selected by an explicit UI-driver launch configuration.
30+type EndpointLaunch struct {
31+ Binary string
32+ Root string
33+ OwnerToken string
34+ Environment []string
35+}
36+
27 func NewDialerFactory() DialerFactory { return DialerFactory{} }37 func NewDialerFactory() DialerFactory { return DialerFactory{} }
28 38
29 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {39 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
@@ -31,6 +41,19 @@ func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserve
31 }41 }
32 42
33 func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) {43 func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) {
44+ return f.dialerForRemote(target, session, mode, log, nil)
45+}
46+
47+// DialerForRemoteWithLaunch selects the same normal carriage while using the
48+// explicit endpoint executable/environment for remote child startup.
49+func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) {
50+ if launch != nil && (launch.Binary == "" || launch.Root == "" || launch.OwnerToken == "") {
51+ return nil, errors.New("vev: isolated remote launch requires a binary, root, and owner")
52+ }
53+ return f.dialerForRemote(target, session, mode, log, launch)
54+}
55+
56+func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) {
34 switch mode {57 switch mode {
35 case TransportUDP:58 case TransportUDP:
36 if log != nil {59 if log != nil {
@@ -38,12 +61,24 @@ func (f DialerFactory) DialerForRemote(target, session string, mode TransportMod
38 }61 }
39 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)62 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
40 dialer.RuntimeObserver = f.observer63 dialer.RuntimeObserver = f.observer
64+ if launch != nil {
65+ dialer.BootstrapBinary = launch.Binary
66+ dialer.BootstrapRoot = launch.Root
67+ dialer.BootstrapOwnerToken = launch.OwnerToken
68+ dialer.BootstrapEnvironment = append([]string(nil), launch.Environment...)
69+ }
41 return dialer, nil70 return dialer, nil
42 case TransportStdio:71 case TransportStdio:
43 if log != nil {72 if log != nil {
44 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)73 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
45 }74 }
46- return stdioDialer{target: target, log: log, observer: f.observer}, nil75+ var selected *EndpointLaunch
76+ if launch != nil {
77+ copyLaunch := *launch
78+ copyLaunch.Environment = append([]string(nil), launch.Environment...)
79+ selected = &copyLaunch
80+ }
81+ return stdioDialer{target: target, log: log, observer: f.observer, launch: selected}, nil
47 default:82 default:
48 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)83 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
49 }84 }
@@ -53,8 +88,12 @@ type stdioDialer struct {
53 target string88 target string
54 log *slog.Logger89 log *slog.Logger
55 observer ports.SerializedRuntimeObserver90 observer ports.SerializedRuntimeObserver
91+ launch *EndpointLaunch
56 }92 }
57 93
58 func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) {94 func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) {
59- return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))95+ if d.launch == nil {
96+ return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
97+ }
98+ return sshstdio.DialContextWithIsolatedLaunch(ctx, d.target, d.launch.Root, d.launch.OwnerToken, d.launch.Binary, d.launch.Environment, d.log, sshstdio.WithRuntimeObserver(d.observer))
60 }99 }
M internal/adapters/remote/remote_factory_test.go +15 -0
@@ -47,6 +47,21 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
47 }47 }
48 }48 }
49 49
50+func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) {
51+ factory := NewDialerFactory()
52+ for _, launch := range []*EndpointLaunch{
53+ {Binary: "/bin/vev"},
54+ {Binary: "/bin/vev", Root: "/tmp/root"},
55+ {Root: "/tmp/root"},
56+ {Root: "/tmp/root", OwnerToken: "token"},
57+ } {
58+ _, err := factory.DialerForRemoteWithLaunch("remote.example", "work", TransportStdio, nil, launch)
59+ if err == nil {
60+ t.Fatalf("DialerForRemoteWithLaunch(%#v) error = nil, want incomplete launch rejection", launch)
61+ }
62+ }
63+}
64+
50 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {65 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
51 factory := NewDialerFactory()66 factory := NewDialerFactory()
52 67
M internal/adapters/sessionwire/client.go +12 -1
@@ -111,12 +111,16 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
112 case wire.MsgParkedRouteResponse:112 case wire.MsgParkedRouteResponse:
113 return wire.UnmarshalParkedRouteResponse(frame.Payload)113 return wire.UnmarshalParkedRouteResponse(frame.Payload)
114+ case wire.MsgUIReceipt:
115+ return wire.UnmarshalUIReceipt(frame.Payload)
116+ case wire.MsgUIViewUpdate:
117+ return wire.UnmarshalUIViewUpdate(frame.Payload)
114 case wire.MsgHello, wire.MsgInput, wire.MsgResize, wire.MsgDetach, wire.MsgPing,118 case wire.MsgHello, wire.MsgInput, wire.MsgResize, wire.MsgDetach, wire.MsgPing,
115 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,119 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,
116 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,120 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
117 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,121 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
118 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,122 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
119- wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure:123+ wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence:
120 return nil, ErrWrongDirection124 return nil, ErrWrongDirection
121 default:125 default:
122 return nil, ErrUnknownMessageType126 return nil, ErrUnknownMessageType
@@ -161,6 +165,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
161 return wire.Frame{Type: wire.MsgCommand, Payload: payload}, err165 return wire.Frame{Type: wire.MsgCommand, Payload: payload}, err
162 case protocol.OutputResetRequest:166 case protocol.OutputResetRequest:
163 return wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(m)}, nil167 return wire.Frame{Type: wire.MsgOutputResetRequest, Payload: wire.MarshalOutputResetRequest(m)}, nil
168+ case protocol.UIFence:
169+ payload, err := wire.MarshalUIFence(m)
170+ return wire.Frame{Type: wire.MsgUIFence, Payload: payload}, err
164 case protocol.RemotePreviewRequest:171 case protocol.RemotePreviewRequest:
165 payload := wire.MarshalRemotePreviewRequest(m)172 payload := wire.MarshalRemotePreviewRequest(m)
166 if payload == nil {173 if payload == nil {
@@ -240,6 +247,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
240 if m != nil {247 if m != nil {
241 return encodeClient(*m)248 return encodeClient(*m)
242 }249 }
250+ case *protocol.UIFence:
251+ if m != nil {
252+ return encodeClient(*m)
253+ }
243 case *protocol.RemotePreviewRequest:254 case *protocol.RemotePreviewRequest:
244 if m != nil {255 if m != nil {
245 return encodeClient(*m)256 return encodeClient(*m)
M internal/adapters/sessionwire/client_test.go +9 -4
@@ -34,6 +34,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
34 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, typeID: wire.MsgClientNotice},34 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, typeID: wire.MsgClientNotice},
35 {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, typeID: wire.MsgCommand},35 {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, typeID: wire.MsgCommand},
36 {name: "reset", message: protocol.OutputResetRequest{}, typeID: wire.MsgOutputResetRequest},36 {name: "reset", message: protocol.OutputResetRequest{}, typeID: wire.MsgOutputResetRequest},
37+ {name: "UI fence", message: protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence},
38+ {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}, typeID: wire.MsgUIFence},
37 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},39 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},
38 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},40 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},
39 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},41 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},
@@ -50,11 +52,14 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
50 require.Equal(t, tt.typeID, raw.sent.Type)52 require.Equal(t, tt.typeID, raw.sent.Type)
51 got, err := decodeClient(raw.sent)53 got, err := decodeClient(raw.sent)
52 require.NoError(t, err)54 require.NoError(t, err)
53- if message, ok := tt.message.(*protocol.SessionCreationFailure); ok {55+ switch message := tt.message.(type) {
56+ case *protocol.SessionCreationFailure:
54 require.Equal(t, *message, got)57 require.Equal(t, *message, got)
55- return58+ case *protocol.UIFence:
59+ require.Equal(t, *message, got)
60+ default:
61+ require.Equal(t, tt.message, got)
56 }62 }
57- require.Equal(t, tt.message, got)
58 })63 })
59 }64 }
60 }65 }
@@ -68,7 +73,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
68 }{73 }{
69 {name: "welcome", message: protocol.Welcome{SessionID: "session"}},74 {name: "welcome", message: protocol.Welcome{SessionID: "session"}},
70 {name: "error", message: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}},75 {name: "error", message: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}},
71- {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}}},76+ {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}},
72 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},77 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
73 {name: "pong", message: protocol.Pong{}},78 {name: "pong", message: protocol.Pong{}},
74 {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},79 {name: "sessions", message: protocol.Sessions{Sessions: []protocol.SessionInfo{}}},
M internal/adapters/sessionwire/server.go +18 -1
@@ -234,11 +234,14 @@ func decodeClient(frame wire.Frame) (protocol.ClientMessage, error) {
234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
235 case wire.MsgSessionCreationFailure:235 case wire.MsgSessionCreationFailure:
236 return wire.UnmarshalSessionCreationFailure(frame.Payload)236 return wire.UnmarshalSessionCreationFailure(frame.Payload)
237+ case wire.MsgUIFence:
238+ return wire.UnmarshalUIFence(frame.Payload)
237 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,239 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,
238 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,240 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,
239 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,241 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
240 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,242 wire.MsgCommittedRouteIdentity, wire.MsgNavigateRecentRoute, wire.MsgRouteCreateSession,
241- wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse:243+ wire.MsgRoutePosition, wire.MsgSamePeerSwitchFailure, wire.MsgParkedRouteResponse,
244+ wire.MsgUIReceipt, wire.MsgUIViewUpdate:
242 return nil, ErrWrongDirection245 return nil, ErrWrongDirection
243 default:246 default:
244 return nil, ErrUnknownMessageType247 return nil, ErrUnknownMessageType
@@ -258,6 +261,12 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
258 case protocol.Output:261 case protocol.Output:
259 payload, err := wire.MarshalOutput(m)262 payload, err := wire.MarshalOutput(m)
260 return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err263 return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err
264+ case protocol.UIReceipt:
265+ payload, err := wire.MarshalUIReceipt(m)
266+ return wire.Frame{Type: wire.MsgUIReceipt, Payload: payload}, err
267+ case protocol.UIViewUpdate:
268+ payload, err := wire.MarshalUIViewUpdate(m)
269+ return wire.Frame{Type: wire.MsgUIViewUpdate, Payload: payload}, err
261 case protocol.Detached:270 case protocol.Detached:
262 if err := protocol.ValidateDetached(m); err != nil {271 if err := protocol.ValidateDetached(m); err != nil {
263 return wire.Frame{}, err272 return wire.Frame{}, err
@@ -323,6 +332,14 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
323 if m != nil {332 if m != nil {
324 return encodeServer(*m)333 return encodeServer(*m)
325 }334 }
335+ case *protocol.UIReceipt:
336+ if m != nil {
337+ return encodeServer(*m)
338+ }
339+ case *protocol.UIViewUpdate:
340+ if m != nil {
341+ return encodeServer(*m)
342+ }
326 case *protocol.Detached:343 case *protocol.Detached:
327 if m != nil {344 if m != nil {
328 return encodeServer(*m)345 return encodeServer(*m)
M internal/adapters/sessionwire/server_test.go +12 -2
@@ -113,7 +113,9 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
113 lease := protocol.ParkedRouteLeaseID{1}113 lease := protocol.ParkedRouteLeaseID{1}
114 welcome := protocol.Welcome{SessionID: "session"}114 welcome := protocol.Welcome{SessionID: "session"}
115 errorMessage := protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}115 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}}116+ output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}
117+ receipt := protocol.UIReceipt{ActionID: 7, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed}
118+ viewUpdate := protocol.UIViewUpdate{Epoch: 1, State: 1, Context: *testUIOutputContext()}
117 detached := protocol.Detached{Reason: protocol.ReasonDetach}119 detached := protocol.Detached{Reason: protocol.ReasonDetach}
118 pong := protocol.Pong{}120 pong := protocol.Pong{}
119 sessions := protocol.Sessions{}121 sessions := protocol.Sessions{}
@@ -131,6 +133,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
131 133
132 outputPayload, err := wire.MarshalOutput(output)134 outputPayload, err := wire.MarshalOutput(output)
133 require.NoError(t, err)135 require.NoError(t, err)
136+ receiptPayload, err := wire.MarshalUIReceipt(receipt)
137+ require.NoError(t, err)
138+ viewUpdatePayload, err := wire.MarshalUIViewUpdate(viewUpdate)
139+ require.NoError(t, err)
134 identityPayload, err := wire.MarshalCommittedRouteIdentity(identity)140 identityPayload, err := wire.MarshalCommittedRouteIdentity(identity)
135 require.NoError(t, err)141 require.NoError(t, err)
136 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)142 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)
@@ -153,6 +159,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
153 {name: "welcome", message: welcome, typeID: wire.MsgWelcome, payload: wire.MarshalWelcome(welcome)},159 {name: "welcome", message: welcome, typeID: wire.MsgWelcome, payload: wire.MarshalWelcome(welcome)},
154 {name: "error", message: errorMessage, typeID: wire.MsgError, payload: wire.MarshalErrorMsg(errorMessage)},160 {name: "error", message: errorMessage, typeID: wire.MsgError, payload: wire.MarshalErrorMsg(errorMessage)},
155 {name: "output", message: output, typeID: wire.MsgOutput, payload: outputPayload},161 {name: "output", message: output, typeID: wire.MsgOutput, payload: outputPayload},
162+ {name: "receipt", message: receipt, typeID: wire.MsgUIReceipt, payload: receiptPayload},
163+ {name: "receipt pointer", message: &receipt, typeID: wire.MsgUIReceipt, payload: receiptPayload},
164+ {name: "view update", message: viewUpdate, typeID: wire.MsgUIViewUpdate, payload: viewUpdatePayload},
165+ {name: "view update pointer", message: &viewUpdate, typeID: wire.MsgUIViewUpdate, payload: viewUpdatePayload},
156 {name: "detached", message: detached, typeID: wire.MsgDetached, payload: wire.MarshalDetached(detached)},166 {name: "detached", message: detached, typeID: wire.MsgDetached, payload: wire.MarshalDetached(detached)},
157 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},167 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},
158 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},168 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},
@@ -248,7 +258,7 @@ func TestServerConnectionPreservesSendModesCapabilitiesAndErrors(t *testing.T) {
248 require.ErrorIs(t, connection.SendServerSynchronous(message), sendErr)258 require.ErrorIs(t, connection.SendServerSynchronous(message), sendErr)
249 require.Equal(t, wire.MsgPong, raw.synchronous.Type)259 require.Equal(t, wire.MsgPong, raw.synchronous.Type)
250 260
251- output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}}261+ output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}
252 require.ErrorIs(t, connection.SendOutput(output), sendErr)262 require.ErrorIs(t, connection.SendOutput(output), sendErr)
253 require.Equal(t, wire.MsgOutput, raw.sent.Type)263 require.Equal(t, wire.MsgOutput, raw.sent.Type)
254 require.ErrorIs(t, connection.SendOutputAsync(output), sendErr)264 require.ErrorIs(t, connection.SendOutputAsync(output), sendErr)
A internal/adapters/sessionwire/ui_test.go +84 -0
@@ -0,0 +1,84 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
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 TestUISynchronizationClientDispatch(t *testing.T) {
13+ for _, message := range []protocol.ClientMessage{
14+ protocol.Input{InputSeq: 3, ActionID: 7, Data: []byte("x")},
15+ protocol.UIFence{ActionID: 7},
16+ } {
17+ raw := &scriptedTransport{}
18+ require.NoError(t, NewClientConnection(raw).SendClient(message))
19+ raw.recv = raw.sent
20+ got, err := NewServerConnection(raw).ReceiveClient()
21+ require.NoError(t, err)
22+ require.Equal(t, message, got)
23+ headerLen := len(raw.sent.Payload)
24+ if input, ok := message.(protocol.Input); ok {
25+ // Input consumes the remaining frame as bytes; only its fixed header
26+ // has strict prefix truncation semantics.
27+ headerLen -= len(input.Data)
28+ }
29+ for size := range headerLen {
30+ raw.recv.Payload = raw.sent.Payload[:size]
31+ _, err := NewServerConnection(raw).ReceiveClient()
32+ require.Error(t, err, "type %d prefix %d", raw.sent.Type, size)
33+ }
34+ }
35+}
36+
37+func testUIOutputContext() *protocol.ViewContext {
38+ return &protocol.ViewContext{Publication: 4,
39+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
40+ TabID: "tab-1", FocusedPaneID: "pane-1"}
41+}
42+
43+func TestUISynchronizationServerDispatch(t *testing.T) {
44+ context := *testUIOutputContext()
45+ for _, test := range []struct {
46+ name string
47+ message protocol.ServerMessage
48+ }{
49+ {"output", protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &context}},
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}},
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}},
55+ {"route create", protocol.RouteCreateSessionAction{CauseActionID: 7, RequestID: 4, SnapshotGeneration: 1, Key: 2, Generation: 3, SessionName: "work"}},
56+ } {
57+ t.Run(test.name, func(t *testing.T) {
58+ for _, async := range []bool{false, true} {
59+ raw := &capableTransport{}
60+ server := NewServerConnection(raw)
61+ var frame wire.Frame
62+ if async {
63+ require.NoError(t, server.SendServerAsync(test.message))
64+ frame = raw.async
65+ } else {
66+ require.NoError(t, server.SendServer(test.message))
67+ frame = raw.sent
68+ }
69+ raw.recv = frame
70+ got, err := NewClientConnection(raw).ReceiveServer()
71+ require.NoError(t, err)
72+ require.Equal(t, test.message, got)
73+ for size := range len(frame.Payload) {
74+ raw.recv.Payload = frame.Payload[:size]
75+ _, err := NewClientConnection(raw).ReceiveServer()
76+ require.Error(t, err, "prefix %d", size)
77+ }
78+ raw.recv.Payload = append(append([]byte(nil), frame.Payload...), 0)
79+ _, err = NewClientConnection(raw).ReceiveServer()
80+ require.Error(t, err)
81+ }
82+ })
83+ }
84+}
A internal/adapters/sshstdio/launch_test.go +98 -0
@@ -0,0 +1,98 @@
1+package sshstdio
2+
3+import (
4+ "os"
5+ "os/exec"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func lookupCommand(t *testing.T, name string) string {
13+ t.Helper()
14+ path, err := exec.LookPath(name)
15+ require.NoError(t, err)
16+ path, err = filepath.Abs(path)
17+ require.NoError(t, err)
18+ return path
19+}
20+
21+func TestBuildCommandForRemoteLaunchQuotesBinaryAndEnvironment(t *testing.T) {
22+ spec := BuildCommandForRemoteLaunch("test@example.com", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio")
23+ require.Equal(t, "ssh", spec.Path)
24+ require.Equal(t, []string{
25+ "--",
26+ "test@example.com",
27+ "'env' '-i' 'HOME=/tmp/home' 'VALUE=a;b'\\''c' '/opt/vev with-space' '_stdio'",
28+ }, spec.Args)
29+}
30+
31+func TestBuildCommandForIsolatedRemoteLaunchQuotesRootAndOwner(t *testing.T) {
32+ spec := BuildCommandForRemoteLaunchWithRoot("test@example.com", "/tmp/root with-space", "owner;token", "/opt/vev with-space", []string{"HOME=/tmp/home", "VALUE=a;b'c"}, "_stdio")
33+ require.Equal(t, "ssh", spec.Path)
34+ require.Equal(t, "test@example.com", spec.Args[1])
35+ require.Contains(t, spec.Args[2], "'sh' '-c'")
36+ require.Contains(t, spec.Args[2], "'/tmp/root with-space'")
37+ require.Contains(t, spec.Args[2], "'owner;token'")
38+ require.NotContains(t, spec.Args[2], "owner;token' &&")
39+ require.Contains(t, spec.Args[2], "VALUE=a;b")
40+}
41+
42+func TestBuildCommandForRemoteCleanupVerifiesOwnerBeforeRemoval(t *testing.T) {
43+ spec := BuildCommandForRemoteCleanup("test@example.com", "/tmp/root", "owner", "/opt/vev", []string{"HOME=/tmp/home"}, "_ui-cleanup")
44+ require.Contains(t, spec.Args[2], "/tmp/root/.vev-ui-driver-owner")
45+ require.Contains(t, spec.Args[2], "'_ui-cleanup'")
46+ require.Contains(t, spec.Args[2], "rm -rf \"$root\"")
47+}
48+
49+func TestIsolatedLaunchScriptRemovesRootWhenCleanupCommandFails(t *testing.T) {
50+ truePath := lookupCommand(t, "true")
51+ falsePath := lookupCommand(t, "false")
52+ tests := []struct {
53+ name string
54+ mode string
55+ cleanup bool
56+ wantExists bool
57+ }{
58+ {name: "launch", mode: "_stdio", wantExists: true},
59+ {name: "cleanup", mode: "_ui-cleanup", cleanup: true, wantExists: false},
60+ }
61+ for _, test := range tests {
62+ t.Run(test.name, func(t *testing.T) {
63+ root := filepath.Join(t.TempDir(), "root")
64+ if test.cleanup {
65+ require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run())
66+ }
67+ err := exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", falsePath, nil, test.mode, test.cleanup)).Run()
68+ var exitErr *exec.ExitError
69+ require.ErrorAs(t, err, &exitErr)
70+ require.Equal(t, 1, exitErr.ExitCode())
71+ _, statErr := os.Stat(root)
72+ if test.wantExists {
73+ require.NoError(t, statErr)
74+ } else {
75+ require.ErrorIs(t, statErr, os.ErrNotExist)
76+ }
77+ })
78+ }
79+}
80+
81+func TestIsolatedLaunchScriptOwnsAndCleansFreshRoot(t *testing.T) {
82+ truePath := lookupCommand(t, "true")
83+ root := filepath.Join(t.TempDir(), "root")
84+ launch := isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)
85+ require.NoError(t, exec.Command("sh", "-c", launch).Run())
86+ marker := filepath.Join(root, ".vev-ui-driver-owner")
87+ data, err := os.ReadFile(marker)
88+ require.NoError(t, err)
89+ require.Equal(t, []byte("owner-token"), data)
90+
91+ // The same owner can reconnect to the endpoint, but another token cannot
92+ // reuse the root created by this invocation.
93+ require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_stdio", false)).Run())
94+ require.Error(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "other-owner", truePath, nil, "_stdio", false)).Run())
95+ require.NoError(t, exec.Command("sh", "-c", isolatedLaunchScript(root, "owner-token", truePath, nil, "_ui-cleanup", true)).Run())
96+ _, err = os.Stat(root)
97+ require.ErrorIs(t, err, os.ErrNotExist)
98+}
M internal/adapters/sshstdio/transport.go +126 -3
@@ -526,6 +526,105 @@ func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec
526 return CommandSpec{Path: "ssh", Args: args}526 return CommandSpec{Path: "ssh", Args: args}
527 }527 }
528 528
529+// BuildCommandForRemoteLaunch prefixes an explicit complete environment and
530+// absolute executable to a hidden remote mode. Each value remains one quoted
531+// remote-shell word; no caller input is concatenated into an unquoted command.
532+func BuildCommandForRemoteLaunch(target, executable string, environment []string, mode string) CommandSpec {
533+ command := make([]string, 0, len(environment)+4)
534+ command = append(command, "env", "-i")
535+ command = append(command, environment...)
536+ command = append(command, executable, mode)
537+ return BuildCommandForRemoteCommand(target, command...)
538+}
539+
540+const isolatedLaunchMarker = ".vev-ui-driver-owner"
541+
542+// BuildCommandForRemoteLaunchWithRoot creates a fresh private endpoint root on
543+// the remote host before starting the selected binary. A stable owner token
544+// lets later connections from the same driver reuse that root while rejecting
545+// a root created by another invocation.
546+func BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec {
547+ if root == "" || ownerToken == "" {
548+ return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1")
549+ }
550+ script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, false)
551+ return BuildCommandForRemoteCommand(target, "sh", "-c", script)
552+}
553+
554+// BuildCommandForRemoteCleanup stops the daemon bound to an owned endpoint and
555+// removes its root only after the owner token has been verified remotely.
556+func BuildCommandForRemoteCleanup(target, root, ownerToken, executable string, environment []string, mode string) CommandSpec {
557+ if root == "" || ownerToken == "" {
558+ return BuildCommandForRemoteCommand(target, "sh", "-c", "exit 1")
559+ }
560+ script := isolatedLaunchScript(root, ownerToken, executable, environment, mode, true)
561+ return BuildCommandForRemoteCommand(target, "sh", "-c", script)
562+}
563+
564+func isolatedLaunchScript(root, ownerToken, executable string, environment []string, mode string, cleanup bool) string {
565+ rootRef := shellQuote(root)
566+ markerRef := shellQuote(root + "/" + isolatedLaunchMarker)
567+ tokenRef := shellQuote(ownerToken)
568+ var script strings.Builder
569+ script.WriteString("set -eu; root=")
570+ script.WriteString(rootRef)
571+ script.WriteString("; marker=")
572+ script.WriteString(markerRef)
573+ script.WriteString("; token=")
574+ script.WriteString(tokenRef)
575+ script.WriteString("; if [ -L \"$root\" ]; then exit 1; fi; ")
576+ if cleanup {
577+ script.WriteString("if [ ! -e \"$root\" ]; then exit 0; fi; ")
578+ }
579+ script.WriteString("if [ -e \"$root\" ]; then ")
580+ script.WriteString("[ -d \"$root\" ] && [ ! -L \"$marker\" ] && [ -f \"$marker\" ] && [ \"$(cat \"$marker\")\" = \"$token\" ] || exit 1; ")
581+ if !cleanup {
582+ script.WriteString("else umask 077; mkdir -m 700 \"$root\"; created=1; ")
583+ script.WriteString("trap 'if [ \"$created\" = 1 ]; then rm -rf \"$root\"; fi' EXIT; ")
584+ for _, child := range []string{"config", "state", "runtime", "tmp"} {
585+ script.WriteString("mkdir -m 700 \"$root/")
586+ script.WriteString(child)
587+ script.WriteString("\"; ")
588+ }
589+ script.WriteString("printf '%s' \"$token\" > \"$marker\"; chmod 600 \"$marker\"; ")
590+ }
591+ script.WriteString("fi; ")
592+ command := make([]string, 0, len(environment)+4)
593+ command = append(command, "env", "-i")
594+ command = append(command, environment...)
595+ command = append(command, executable, mode)
596+ if cleanup {
597+ script.WriteString("command_status=0; if ")
598+ script.WriteString(quotedWords(command))
599+ script.WriteString("; then :; else command_status=$?; fi; rm_status=0; rm -rf \"$root\" || rm_status=$?; if [ \"$command_status\" -ne 0 ]; then exit \"$command_status\"; fi; exit \"$rm_status\"")
600+ } else {
601+ script.WriteString("trap - EXIT; exec ")
602+ script.WriteString(quotedWords(command))
603+ }
604+ return script.String()
605+}
606+
607+func quotedWords(words []string) string {
608+ quoted := make([]string, 0, len(words))
609+ for _, word := range words {
610+ quoted = append(quoted, shellQuote(word))
611+ }
612+ return strings.Join(quoted, " ")
613+}
614+
615+// RunRemoteCommand executes a command through the local ssh binary without
616+// exposing its output, which may contain configured environment or endpoint
617+// values.
618+func RunRemoteCommand(ctx context.Context, spec CommandSpec) error {
619+ if err := ctx.Err(); err != nil {
620+ return err
621+ }
622+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
623+ cmd.Stdout = io.Discard
624+ cmd.Stderr = io.Discard
625+ return cmd.Run()
626+}
627+
529 func shellQuote(s string) string {628 func shellQuote(s string) string {
530 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"629 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
531 }630 }
@@ -542,6 +641,19 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
542 return dialContext(ctx, target, session, logger, opts...)641 return dialContext(ctx, target, session, logger, opts...)
543 }642 }
544 643
644+// DialContextWithLaunch is the explicit-environment variant used by isolated
645+// driver endpoints. An empty executable preserves the normal `vev _stdio`
646+// command.
647+func DialContextWithLaunch(ctx context.Context, target, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) {
648+ return dialContextWithLaunch(ctx, target, "", "", executable, environment, logger, opts...)
649+}
650+
651+// DialContextWithIsolatedLaunch starts a remote child after it has exclusively
652+// created or revalidated its private endpoint root.
653+func DialContextWithIsolatedLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, logger *slog.Logger, opts ...Option) (wire.Transport, error) {
654+ return dialContextWithLaunch(ctx, target, root, ownerToken, executable, environment, logger, opts...)
655+}
656+
545 // DialContext is like Dial, but the context gates ssh startup. Once the657 // DialContext is like Dial, but the context gates ssh startup. Once the
546 // transport is returned, its Close method owns the subprocess lifetime; the658 // transport is returned, its Close method owns the subprocess lifetime; the
547 // handshake context must not kill an already-established carriage. Callers may659 // handshake context must not kill an already-established carriage. Callers may
@@ -556,10 +668,21 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
556 }668 }
557 669
558 func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) {670 func dialContext(ctx context.Context, target, session string, log *slog.Logger, opts ...Option) (wire.Transport, error) {
671+ return dialContextWithLaunch(ctx, target, "", "", "", nil, log, opts...)
672+}
673+
674+func dialContextWithLaunch(ctx context.Context, target, root, ownerToken, executable string, environment []string, log *slog.Logger, opts ...Option) (wire.Transport, error) {
559 if err := ctx.Err(); err != nil {675 if err := ctx.Err(); err != nil {
560 return nil, err676 return nil, err
561 }677 }
562- spec := BuildCommand(target, session)678+ spec := BuildCommand(target, "")
679+ if executable != "" {
680+ if root != "" && ownerToken != "" {
681+ spec = BuildCommandForRemoteLaunchWithRoot(target, root, ownerToken, executable, environment, "_stdio")
682+ } else {
683+ spec = BuildCommandForRemoteLaunch(target, executable, environment, "_stdio")
684+ }
685+ }
563 // The caller's context is also the bounded protocol-handshake context and is686 // The caller's context is also the bounded protocol-handshake context and is
564 // canceled after the first committed publication. Binding it to the command687 // canceled after the first committed publication. Binding it to the command
565 // would kill a healthy long-lived carriage at that boundary. Transport.Close688 // would kill a healthy long-lived carriage at that boundary. Transport.Close
@@ -578,12 +701,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
578 cmd.Stderr = &stderr701 cmd.Stderr = &stderr
579 if err := cmd.Start(); err != nil {702 if err := cmd.Start(); err != nil {
580 if log != nil {703 if log != nil {
581- log.Error("ssh start failed", "target", target, "session", session, "err", err)704+ log.Error("ssh start failed", "target", target, "session", "", "err", err)
582 }705 }
583 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)706 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
584 }707 }
585 708
586- waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)709+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, "")
587 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)710 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
588 if err := ctx.Err(); err != nil {711 if err := ctx.Err(); err != nil {
589 _ = transport.Close()712 _ = transport.Close()
M internal/adapters/term/batchwriter.go +34 -4
@@ -4,6 +4,8 @@ import (
4 "bufio"4 "bufio"
5 "io"5 "io"
6 "sync"6 "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
7 )9 )
8 10
9 // batchWriter is a thread-safe buffered writer. Writes accumulate in an11 // batchWriter is a thread-safe buffered writer. Writes accumulate in an
@@ -11,13 +13,35 @@ import (
11 // buffer's capacity passes straight through to the underlying sink13 // buffer's capacity passes straight through to the underlying sink
12 // (bufio.Writer's standard behavior).14 // (bufio.Writer's standard behavior).
13 type batchWriter struct {15 type batchWriter struct {
14- mu sync.Mutex16+ mu sync.Mutex
15- bw *bufio.Writer17+ bw *bufio.Writer
18+ sink ports.UIObservationSink
19+}
20+
21+type observedWriter struct {
22+ out io.Writer
23+ sink ports.UIObservationSink
24+}
25+
26+func (w observedWriter) Write(p []byte) (int, error) {
27+ n, err := w.out.Write(p)
28+ if n > 0 {
29+ w.sink.ObserveTerminalWrite(p[:n])
30+ }
31+ return n, err
16 }32 }
17 33
18 // newBatchWriter wraps out with a buffer of the given size.34 // newBatchWriter wraps out with a buffer of the given size.
19 func newBatchWriter(out io.Writer, size int) *batchWriter {35 func newBatchWriter(out io.Writer, size int) *batchWriter {
20- return &batchWriter{bw: bufio.NewWriterSize(out, size)}36+ return newBatchWriterWithObservation(out, size, nil)
37+}
38+
39+func newBatchWriterWithObservation(out io.Writer, size int, sink ports.UIObservationSink) *batchWriter {
40+ writer := out
41+ if sink != nil {
42+ writer = observedWriter{out: out, sink: sink}
43+ }
44+ return &batchWriter{bw: bufio.NewWriterSize(writer, size), sink: sink}
21 }45 }
22 46
23 // Write buffers p, flushing to the underlying sink only as bufio.Writer47 // Write buffers p, flushing to the underlying sink only as bufio.Writer
@@ -39,7 +63,13 @@ func (w *batchWriter) WriteString(s string) (int, error) {
39 func (w *batchWriter) Flush() error {63 func (w *batchWriter) Flush() error {
40 w.mu.Lock()64 w.mu.Lock()
41 defer w.mu.Unlock()65 defer w.mu.Unlock()
42- return w.bw.Flush()66+ if err := w.bw.Flush(); err != nil {
67+ return err
68+ }
69+ if w.sink != nil {
70+ w.sink.ObserveTerminalFlush()
71+ }
72+ return nil
43 }73 }
44 74
45 // Buffered reports the number of bytes currently buffered and not yet75 // Buffered reports the number of bytes currently buffered and not yet
M internal/adapters/term/batchwriter_test.go +54 -0
@@ -3,6 +3,8 @@ package term
3 import (3 import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
6 )8 )
7 9
8 // recordingWriter records each Write call it receives, copying the bytes10 // recordingWriter records each Write call it receives, copying the bytes
@@ -32,6 +34,19 @@ func (r *recordingWriter) all() []byte {
32 return out34 return out
33 }35 }
34 36
37+type recordingObservation struct {
38+ writes [][]byte
39+ flushes int
40+ invalidated bool
41+}
42+
43+func (r *recordingObservation) ObserveTerminalWrite(data []byte) {
44+ r.writes = append(r.writes, append([]byte(nil), data...))
45+}
46+func (r *recordingObservation) ObserveTerminalFlush() { r.flushes++ }
47+func (r *recordingObservation) ObserveTerminalResize(domain.Geometry) {}
48+func (r *recordingObservation) InvalidateTerminalObservation() { r.invalidated = true }
49+
35 func TestBatchWriter_BufferedUntilFlush(t *testing.T) {50 func TestBatchWriter_BufferedUntilFlush(t *testing.T) {
36 rw := &recordingWriter{}51 rw := &recordingWriter{}
37 bw := newBatchWriter(rw, 64)52 bw := newBatchWriter(rw, 64)
@@ -128,3 +143,42 @@ func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) {
128 t.Fatalf("expected no sink writes, got %d", len(rw.writes))143 t.Fatalf("expected no sink writes, got %d", len(rw.writes))
129 }144 }
130 }145 }
146+
147+func TestBatchWriter_ObservesOnlySuccessfulPhysicalWrites(t *testing.T) {
148+ rw := &recordingWriter{}
149+ observation := &recordingObservation{}
150+ bw := newBatchWriterWithObservation(rw, 4, observation)
151+
152+ if _, err := bw.WriteString("abc"); err != nil {
153+ t.Fatal(err)
154+ }
155+ if _, err := bw.WriteString("defgh"); err != nil {
156+ t.Fatal(err)
157+ }
158+ if got := string(observation.writes[0]); got != "abcd" {
159+ t.Fatalf("observed auto-flush prefix = %q", got)
160+ }
161+ if observation.flushes != 0 {
162+ t.Fatal("auto-flush ended a publication transaction")
163+ }
164+ if err := bw.Flush(); err != nil {
165+ t.Fatal(err)
166+ }
167+ if observation.flushes != 1 {
168+ t.Fatalf("flush boundaries = %d, want 1", observation.flushes)
169+ }
170+ if got := string(append(observation.writes[0], observation.writes[1]...)); got != "abcdefgh" {
171+ t.Fatalf("observed successful prefixes = %q", got)
172+ }
173+
174+ rw.failNext = true
175+ if _, err := bw.WriteString("x"); err != nil {
176+ t.Fatal(err)
177+ }
178+ if err := bw.Flush(); err == nil {
179+ t.Fatal("failed physical flush returned nil")
180+ }
181+ if len(observation.writes) != 2 || observation.flushes != 1 {
182+ t.Fatalf("failed output was observed: writes=%d flushes=%d", len(observation.writes), observation.flushes)
183+ }
184+}
M internal/adapters/term/terminal.go +36 -46
@@ -17,38 +17,6 @@ import (
17 // Compile-time check that Terminal satisfies ports.Terminal.17 // Compile-time check that Terminal satisfies ports.Terminal.
18 var _ ports.Terminal = (*Terminal)(nil)18 var _ ports.Terminal = (*Terminal)(nil)
19 19
20-// Escape sequences for alt-screen and cursor visibility control. All
21-// emissions go through the batched writer and are explicitly flushed.
22-const (
23- altScreenEnter = "\x1b[?1049h"
24- altScreenExit = "\x1b[?1049l"
25- cursorHide = "\x1b[?25l"
26- cursorShow = "\x1b[?25h"
27- cursorStyleDefault = "\x1b[0 q"
28- mouseEnable = "\x1b[?1002h\x1b[?1006h"
29- mouseDisable = "\x1b[?1002l\x1b[?1006l"
30- bracketedPasteEnable = "\x1b[?2004h"
31- bracketedPasteDisable = "\x1b[?2004l"
32- colorSchemeEnable = "\x1b[?2031h"
33- colorSchemeDisable = "\x1b[?2031l"
34- // DECAWM. vev addresses every row explicitly and never relies on the
35- // terminal wrapping its output, so autowrap stays off while it owns the
36- // alt screen: a frame the daemon composed for a wider terminal is then
37- // clipped at the right margin instead of bleeding a styled run onto the
38- // next row. Restored alongside the other modes, since DECAWM is global
39- // and is not saved by the alt-screen switch.
40- autowrapDisable = "\x1b[?7l"
41- autowrapEnable = "\x1b[?7h"
42-)
43-
44-// visualRestore turns off every mode EnterRaw turns on, in reverse order, and
45-// hands the normal screen back. Shared by the ordinary restore and the direct
46-// fallback so the two can never drift: a partial emission may have left any
47-// prefix of the enter sequence in effect, so the reset has to cover all of it.
48-// Every mode reset here is idempotent.
49-const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
50- bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
51-
52 // bufSize is the batched writer's buffer capacity.20 // bufSize is the batched writer's buffer capacity.
53 const bufSize = 64 * 102421 const bufSize = 64 * 1024
54 22
@@ -58,12 +26,14 @@ const bufSize = 64 * 1024
58 //26 //
59 // Zero value is not usable; use New or NewWithFiles.27 // Zero value is not usable; use New or NewWithFiles.
60 type Terminal struct {28 type Terminal struct {
61- in *os.File29+ in *os.File
62- out *os.File30+ out *os.File
63- fd int31+ fd int
64- bw *batchWriter32+ bw *batchWriter
33+ observation ports.UIObservationSink
65 34
66 mu sync.Mutex35 mu sync.Mutex
36+ geometryMu sync.Mutex // serializes winsize reads with observation publication
67 orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)37 orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
68 entered bool // true between a successful EnterRaw and its restore38 entered bool // true between a successful EnterRaw and its restore
69 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped39 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
@@ -89,11 +59,18 @@ func New() *Terminal {
89 // exists so tests can inject a PTY pair (or a plain pipe, for the59 // exists so tests can inject a PTY pair (or a plain pipe, for the
90 // non-tty paths) in place of the process's real stdin/stdout.60 // non-tty paths) in place of the process's real stdin/stdout.
91 func NewWithFiles(in, out *os.File) *Terminal {61 func NewWithFiles(in, out *os.File) *Terminal {
62+ return NewWithFilesAndObservation(in, out, nil)
63+}
64+
65+// NewWithFilesAndObservation creates a terminal that mirrors successfully
66+// emitted byte prefixes and flush boundaries to observation.
67+func NewWithFilesAndObservation(in, out *os.File, observation ports.UIObservationSink) *Terminal {
92 return &Terminal{68 return &Terminal{
93- in: in,69+ in: in,
94- out: out,70+ out: out,
95- fd: int(in.Fd()),71+ fd: int(in.Fd()),
96- bw: newBatchWriter(out, bufSize),72+ bw: newBatchWriterWithObservation(out, bufSize, observation),
73+ observation: observation,
97 }74 }
98 }75 }
99 76
@@ -123,7 +100,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
123 }100 }
124 t.orig = old101 t.orig = old
125 102
126- if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {103+ if _, err := t.bw.Write(VisualEnterSequence()); err != nil {
127 t.resetVisualModesDirect()104 t.resetVisualModesDirect()
128 _ = t.restoreRawLocked()105 _ = t.restoreRawLocked()
129 return nil, fmt.Errorf("term: enter alt screen: %w", err)106 return nil, fmt.Errorf("term: enter alt screen: %w", err)
@@ -147,7 +124,10 @@ func (t *Terminal) EnterRaw() (func() error, error) {
147 // harmless. Best effort by construction — the descriptor is usually broken124 // harmless. Best effort by construction — the descriptor is usually broken
148 // too. Must be called with t.mu held.125 // too. Must be called with t.mu held.
149 func (t *Terminal) resetVisualModesDirect() {126 func (t *Terminal) resetVisualModesDirect() {
150- _, _ = t.out.WriteString(visualRestore)127+ if t.observation != nil {
128+ t.observation.InvalidateTerminalObservation()
129+ }
130+ _, _ = t.out.Write(VisualRestoreSequence())
151 }131 }
152 132
153 // makeRestoreLocked returns an idempotent restore closure for the133 // makeRestoreLocked returns an idempotent restore closure for the
@@ -170,7 +150,7 @@ func (t *Terminal) restore() error {
170 t.mu.Lock()150 t.mu.Lock()
171 defer t.mu.Unlock()151 defer t.mu.Unlock()
172 152
173- _, werr := t.bw.WriteString(visualRestore)153+ _, werr := t.bw.Write(VisualRestoreSequence())
174 ferr := t.bw.Flush()154 ferr := t.bw.Flush()
175 if werr != nil || ferr != nil {155 if werr != nil || ferr != nil {
176 t.resetVisualModesDirect()156 t.resetVisualModesDirect()
@@ -209,15 +189,21 @@ func (t *Terminal) restoreRawLocked() error {
209 // Geometry returns the current terminal cell dimensions and optional pixel189 // Geometry returns the current terminal cell dimensions and optional pixel
210 // dimensions reported by the controlling terminal.190 // dimensions reported by the controlling terminal.
211 func (t *Terminal) Geometry() (domain.Geometry, error) {191 func (t *Terminal) Geometry() (domain.Geometry, error) {
192+ t.geometryMu.Lock()
193+ defer t.geometryMu.Unlock()
212 ws, err := rawterm.GetWinsize(t.fd)194 ws, err := rawterm.GetWinsize(t.fd)
213 if err != nil {195 if err != nil {
214 return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)196 return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
215 }197 }
216- return (domain.Geometry{198+ geometry := (domain.Geometry{
217 Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},199 Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
218 PixelWidth: int(ws.Xpixel),200 PixelWidth: int(ws.Xpixel),
219 PixelHeight: int(ws.Ypixel),201 PixelHeight: int(ws.Ypixel),
220- }).NormalizePixels(), nil202+ }).NormalizePixels()
203+ if t.observation != nil {
204+ t.observation.ObserveTerminalResize(geometry)
205+ }
206+ return geometry, nil
221 }207 }
222 208
223 // ResizeEvents returns a channel of coalesced terminal sizes, one per209 // ResizeEvents returns a channel of coalesced terminal sizes, one per
@@ -284,5 +270,9 @@ func (t *Terminal) Out() io.Writer {
284 270
285 // Flush writes any buffered output to the terminal device.271 // Flush writes any buffered output to the terminal device.
286 func (t *Terminal) Flush() error {272 func (t *Terminal) Flush() error {
287- return t.bw.Flush()273+ err := t.bw.Flush()
274+ if err != nil && t.observation != nil {
275+ t.observation.InvalidateTerminalObservation()
276+ }
277+ return err
288 }278 }
A internal/adapters/term/visual.go +29 -0
@@ -0,0 +1,29 @@
1+package term
2+
3+const (
4+ altScreenEnter = "\x1b[?1049h"
5+ altScreenExit = "\x1b[?1049l"
6+ cursorHide = "\x1b[?25l"
7+ cursorShow = "\x1b[?25h"
8+ cursorStyleDefault = "\x1b[0 q"
9+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
10+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
11+ bracketedPasteEnable = "\x1b[?2004h"
12+ bracketedPasteDisable = "\x1b[?2004l"
13+ colorSchemeEnable = "\x1b[?2031h"
14+ colorSchemeDisable = "\x1b[?2031l"
15+ autowrapDisable = "\x1b[?7l"
16+ autowrapEnable = "\x1b[?7h"
17+)
18+
19+const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable
20+
21+const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
22+ bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
23+
24+// VisualEnterSequence returns a fresh copy of the visual initialization used
25+// after acquiring a terminal. It intentionally excludes OS raw-mode setup.
26+func VisualEnterSequence() []byte { return []byte(visualEnter) }
27+
28+// VisualRestoreSequence returns a fresh copy of the idempotent visual cleanup.
29+func VisualRestoreSequence() []byte { return []byte(visualRestore) }
A internal/adapters/uidriver/peer_darwin.go +25 -0
@@ -0,0 +1,25 @@
1+//go:build darwin
2+
3+package uidriver
4+
5+import (
6+ "net"
7+ "os"
8+
9+ "golang.org/x/sys/unix"
10+)
11+
12+func peerIsCurrentUser(conn *net.UnixConn) bool {
13+ raw, err := conn.SyscallConn()
14+ if err != nil {
15+ return false
16+ }
17+ var credential *unix.Xucred
18+ var controlErr error
19+ if err := raw.Control(func(fd uintptr) {
20+ credential, controlErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
21+ }); err != nil || controlErr != nil || credential == nil {
22+ return false
23+ }
24+ return uint32(os.Geteuid()) == credential.Uid
25+}
A internal/adapters/uidriver/peer_linux.go +24 -0
@@ -0,0 +1,24 @@
1+//go:build linux
2+
3+package uidriver
4+
5+import (
6+ "net"
7+ "os"
8+ "syscall"
9+)
10+
11+func peerIsCurrentUser(conn *net.UnixConn) bool {
12+ raw, err := conn.SyscallConn()
13+ if err != nil {
14+ return false
15+ }
16+ var credential *syscall.Ucred
17+ var controlErr error
18+ if err := raw.Control(func(fd uintptr) {
19+ credential, controlErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
20+ }); err != nil || controlErr != nil || credential == nil {
21+ return false
22+ }
23+ return uint32(os.Geteuid()) == credential.Uid
24+}
A internal/adapters/uidriver/peer_other.go +10 -0
@@ -0,0 +1,10 @@
1+//go:build !linux && !darwin && !windows
2+
3+package uidriver
4+
5+import "net"
6+
7+// Unsupported systems rely on the private 0700 parent and 0600 socket
8+// permissions because no platform-specific peer credential implementation is
9+// available here.
10+func peerIsCurrentUser(*net.UnixConn) bool { return true }
A internal/adapters/uidriver/peer_windows.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build windows
2+
3+package uidriver
4+
5+import "net"
6+
7+// Windows does not expose a peer-credential check through this adapter yet.
8+// Fail closed rather than accepting an unverified UI-driver connection.
9+func peerIsCurrentUser(*net.UnixConn) bool { return false }
A internal/adapters/uidriver/request.go +267 -0
@@ -0,0 +1,267 @@
1+// Package uidriver exposes attachment UI primitives through bounded JSONL.
2+package uidriver
3+
4+import (
5+ "bytes"
6+ "encoding/json"
7+ "io"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+const (
17+ apiVersion = 1
18+ maxRequestBytes = 64 << 10
19+ maxResponseBytes = 48 << 20
20+ maxSerializedBytes = 128 << 20
21+ maxConnections = 4
22+ maxColumns = 512
23+ maxRows = 256
24+ writeTimeout = 5 * time.Second
25+)
26+
27+type operation string
28+
29+const (
30+ opCapture operation = "capture"
31+ opKeys operation = "keys"
32+ opText operation = "text"
33+ opWait operation = "wait"
34+)
35+
36+type captureFormat string
37+
38+const (
39+ formatText captureFormat = "text"
40+ formatCells captureFormat = "cells"
41+ formatBoth captureFormat = "both"
42+)
43+
44+type request struct {
45+ ID uint64
46+ Op operation
47+ Attachment string
48+ Format captureFormat
49+ Action ports.UIActionRequest
50+ Wait ports.UIWaitRequest
51+}
52+
53+func invalidRequest() error { return &ports.UIError{Code: ports.UIErrInvalidRequest} }
54+
55+// decodeRequest validates the exact operation schema before touching a service.
56+// The standard decoder's case-insensitive struct matching is deliberately not
57+// used for member names; null is not a substitute for an omitted option.
58+func decodeRequest(data []byte) (request, error) {
59+ var result request
60+ if len(data) > maxRequestBytes || !utf8.Valid(data) || !uniqueJSON(data) {
61+ return result, invalidRequest()
62+ }
63+ var fields map[string]json.RawMessage
64+ if json.Unmarshal(data, &fields) != nil || fields == nil {
65+ return result, invalidRequest()
66+ }
67+ field := func(name string, out any, required bool) bool {
68+ raw, exists := fields[name]
69+ if !exists {
70+ return !required
71+ }
72+ return !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) && json.Unmarshal(raw, out) == nil
73+ }
74+ var version uint16
75+ if !field("id", &result.ID, true) || result.ID == 0 || !field("version", &version, true) {
76+ return result, invalidRequest()
77+ }
78+ if version != apiVersion {
79+ return result, &ports.UIError{Code: ports.UIErrUnsupportedVersion}
80+ }
81+ if !field("op", &result.Op, true) || !field("attachment", &result.Attachment, true) || result.Attachment == "" {
82+ return result, invalidRequest()
83+ }
84+ allowed := map[string]bool{"version": true, "id": true, "op": true, "attachment": true}
85+ allow := func(names ...string) {
86+ for _, name := range names {
87+ allowed[name] = true
88+ }
89+ }
90+ var timeoutMS int64
91+ switch result.Op {
92+ case opCapture:
93+ allow("format")
94+ result.Format = formatText
95+ if !field("format", &result.Format, false) || result.Format != formatText && result.Format != formatCells && result.Format != formatBoth {
96+ return result, invalidRequest()
97+ }
98+ case opKeys, opText:
99+ allow("generation", "timeout_ms")
100+ result.Action.Attachment = result.Attachment
101+ if !field("generation", &result.Action.Generation, true) || result.Action.Generation == 0 {
102+ return result, invalidRequest()
103+ }
104+ if result.Op == opKeys {
105+ allow("keys")
106+ if !field("keys", &result.Action.Keys, true) || len(result.Action.Keys) == 0 || len(result.Action.Keys) > 256 {
107+ return result, invalidRequest()
108+ }
109+ } else {
110+ allow("text")
111+ if !field("text", &result.Action.Text, true) || result.Action.Text == "" {
112+ return result, invalidRequest()
113+ }
114+ }
115+ case opWait:
116+ allow("after_action", "timeout_ms", "expect")
117+ result.Wait.Attachment = result.Attachment
118+ if !field("after_action", &result.Wait.AfterAction, false) {
119+ return result, invalidRequest()
120+ }
121+ if _, exists := fields["after_action"]; exists && result.Wait.AfterAction == 0 {
122+ return result, invalidRequest()
123+ }
124+ expect, err := decodeExpect(fields["expect"])
125+ if err != nil {
126+ return result, err
127+ }
128+ result.Wait.Expect = expect
129+ default:
130+ return result, invalidRequest()
131+ }
132+ for name := range fields {
133+ if !allowed[name] {
134+ return result, invalidRequest()
135+ }
136+ }
137+ if raw, exists := fields["timeout_ms"]; exists {
138+ if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) || json.Unmarshal(raw, &timeoutMS) != nil || timeoutMS < 1 || timeoutMS > 30000 {
139+ return result, invalidRequest()
140+ }
141+ }
142+ result.Action.Timeout = time.Duration(timeoutMS) * time.Millisecond
143+ result.Wait.Timeout = result.Action.Timeout
144+ return result, nil
145+}
146+
147+func decodeExpect(data []byte) (ports.UIExpect, error) {
148+ var result ports.UIExpect
149+ var fields map[string]json.RawMessage
150+ if json.Unmarshal(data, &fields) != nil || len(fields) == 0 {
151+ return result, invalidRequest()
152+ }
153+ for name, raw := range fields {
154+ if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
155+ return result, invalidRequest()
156+ }
157+ switch name {
158+ case "text_contains":
159+ var text string
160+ if json.Unmarshal(raw, &text) != nil || len(text) > 4096 {
161+ return result, invalidRequest()
162+ }
163+ result.TextContains = &text
164+ case "status":
165+ var status ports.UIPresentationStatus
166+ if json.Unmarshal(raw, &status) != nil {
167+ return result, invalidRequest()
168+ }
169+ switch status {
170+ case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached:
171+ default:
172+ return result, invalidRequest()
173+ }
174+ result.Status = &status
175+ case "session":
176+ object, ok := exactStringObject(raw, "lifecycle_id", "session_name")
177+ if !ok {
178+ return result, invalidRequest()
179+ }
180+ target := protocol.ExactSessionTarget{SessionName: object["session_name"]}
181+ if target.LifecycleID.UnmarshalText([]byte(object["lifecycle_id"])) != nil || target.Validate() != nil {
182+ return result, invalidRequest()
183+ }
184+ result.Session = &target
185+ case "focus":
186+ object, ok := exactStringObject(raw, "tab_id", "pane_id")
187+ if !ok {
188+ return result, invalidRequest()
189+ }
190+ focus := &ports.UIFocus{TabID: domain.TabStableID(object["tab_id"]), PaneID: domain.PaneStableID(object["pane_id"])}
191+ if domain.ValidateTabStableID(focus.TabID) != nil || domain.ValidatePaneStableID(focus.PaneID) != nil {
192+ return result, invalidRequest()
193+ }
194+ result.Focus = focus
195+ default:
196+ return result, invalidRequest()
197+ }
198+ }
199+ return result, nil
200+}
201+
202+func exactStringObject(data []byte, names ...string) (map[string]string, bool) {
203+ var object map[string]string
204+ if json.Unmarshal(data, &object) != nil || len(object) != len(names) {
205+ return nil, false
206+ }
207+ for _, name := range names {
208+ if object[name] == "" {
209+ return nil, false
210+ }
211+ }
212+ return object, true
213+}
214+
215+// uniqueJSON rejects duplicate members at every depth, including escaped aliases.
216+// Input is already byte-bounded. A small depth cap exceeds every API schema.
217+func uniqueJSON(data []byte) bool {
218+ decoder := json.NewDecoder(bytes.NewReader(data))
219+ decoder.UseNumber()
220+ var value func(int) bool
221+ value = func(depth int) bool {
222+ if depth > 8 {
223+ return false
224+ }
225+ token, err := decoder.Token()
226+ if err != nil {
227+ return false
228+ }
229+ delimiter, ok := token.(json.Delim)
230+ if !ok {
231+ return true
232+ }
233+ switch delimiter {
234+ case '{':
235+ seen := make(map[string]bool)
236+ for decoder.More() {
237+ token, err := decoder.Token()
238+ if err != nil {
239+ return false
240+ }
241+ name, ok := token.(string)
242+ if !ok || seen[name] {
243+ return false
244+ }
245+ seen[name] = true
246+ if !value(depth + 1) {
247+ return false
248+ }
249+ }
250+ case '[':
251+ for decoder.More() {
252+ if !value(depth + 1) {
253+ return false
254+ }
255+ }
256+ default:
257+ return false
258+ }
259+ _, err = decoder.Token()
260+ return err == nil
261+ }
262+ if !value(0) {
263+ return false
264+ }
265+ _, err := decoder.Token()
266+ return err == io.EOF
267+}
A internal/adapters/uidriver/request_test.go +63 -0
@@ -0,0 +1,63 @@
1+package uidriver
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestDecodeRequestStrictOperationSchemas(t *testing.T) {
13+ for _, tt := range []struct {
14+ name, input string
15+ code ports.UIErrorCode
16+ }{
17+ {"capture", `{"version":1,"id":1,"op":"capture","attachment":"a"}`, ""},
18+ {"keys", `{"version":1,"id":1,"op":"keys","attachment":"a","generation":2,"keys":["Alt+Space"],"timeout_ms":30}`, ""},
19+ {"text", `{"version":1,"id":1,"op":"text","attachment":"a","generation":2,"text":"hello"}`, ""},
20+ {"wait", `{"version":1,"id":1,"op":"wait","attachment":"a","after_action":3,"expect":{"text_contains":"hello","status":"attached","focus":{"tab_id":"t","pane_id":"p"},"session":{"lifecycle_id":"01000000000000000000000000000000","session_name":"work"}}}`, ""},
21+ {"unknown", `{"version":1,"id":1,"op":"capture","attachment":"a","extra":0}`, ports.UIErrInvalidRequest},
22+ {"wrong case", `{"Version":1,"id":1,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
23+ {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
24+ {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
25+ {"nested duplicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"attached","status":"detached"}}`, ports.UIErrInvalidRequest},
26+ {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest},
27+ {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest},
28+ {"empty predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{}}`, ports.UIErrInvalidRequest},
29+ {"null option", `{"version":1,"id":1,"op":"capture","attachment":"a","format":null}`, ports.UIErrInvalidRequest},
30+ {"zero id", `{"version":1,"id":0,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
31+ {"negative id", `{"version":1,"id":-1,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
32+ {"wrong version", `{"version":2,"id":1,"op":"capture","attachment":"a"}`, ports.UIErrUnsupportedVersion},
33+ {"mixed operation", `{"version":1,"id":1,"op":"keys","attachment":"a","generation":2,"keys":["x"],"text":"x"}`, ports.UIErrInvalidRequest},
34+ {"capture generation", `{"version":1,"id":1,"op":"capture","attachment":"a","generation":2}`, ports.UIErrInvalidRequest},
35+ {"zero generation", `{"version":1,"id":1,"op":"text","attachment":"a","generation":0,"text":"x"}`, ports.UIErrInvalidRequest},
36+ {"timeout bound", `{"version":1,"id":1,"op":"text","attachment":"a","generation":2,"text":"x","timeout_ms":30001}`, ports.UIErrInvalidRequest},
37+ {"truncated", `{"version":1`, ports.UIErrInvalidRequest},
38+ {"trailing", `{"version":1,"id":1,"op":"capture","attachment":"a"}{}`, ports.UIErrInvalidRequest},
39+ {"oversized", strings.Repeat(" ", maxRequestBytes+1), ports.UIErrInvalidRequest},
40+ } {
41+ t.Run(tt.name, func(t *testing.T) {
42+ decoded, err := decodeRequest([]byte(tt.input))
43+ if tt.code == "" {
44+ require.NoError(t, err)
45+ require.Equal(t, uint64(1), decoded.ID)
46+ } else {
47+ var uiErr *ports.UIError
48+ require.ErrorAs(t, err, &uiErr)
49+ require.Equal(t, tt.code, uiErr.Code)
50+ }
51+ })
52+ }
53+}
54+
55+func TestDecodeRequestDefaultsAndTypedValues(t *testing.T) {
56+ capture, err := decodeRequest([]byte(`{"version":1,"id":1,"op":"capture","attachment":"a"}`))
57+ require.NoError(t, err)
58+ require.Equal(t, formatText, capture.Format)
59+ keys, err := decodeRequest([]byte(`{"version":1,"id":2,"op":"keys","attachment":"a","generation":4,"keys":["Escape"],"timeout_ms":25}`))
60+ require.NoError(t, err)
61+ require.Equal(t, 25*time.Millisecond, keys.Action.Timeout)
62+ require.Equal(t, uint64(4), keys.Action.Generation)
63+}
A internal/adapters/uidriver/response.go +230 -0
@@ -0,0 +1,230 @@
1+package uidriver
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+type envelope struct {
14+ Version int `json:"version"`
15+ ID uint64 `json:"id"`
16+ Result any `json:"result,omitempty"`
17+ Error *responseError `json:"error,omitempty"`
18+}
19+
20+type responseError struct {
21+ Code ports.UIErrorCode `json:"code"`
22+ Message string `json:"message"`
23+ Accepted bool `json:"accepted"`
24+ ActionID uint64 `json:"action_id,omitempty"`
25+}
26+
27+// Ready is connection discovery, never an attachment registry.
28+type Ready struct {
29+ Attachment string `json:"attachment"`
30+ Generation uint64 `json:"generation"`
31+ Control bool `json:"control"`
32+ Status ports.UIPresentationStatus `json:"status"`
33+}
34+
35+type contextResponse struct {
36+ Attachment string `json:"attachment"`
37+ Generation uint64 `json:"generation"`
38+ Session sessionResponse `json:"session"`
39+ Focus focusResponse `json:"focus"`
40+ Epoch uint64 `json:"output_epoch"`
41+ State uint64 `json:"output_state"`
42+ ViewRevision uint64 `json:"view_revision"`
43+ ViewPublication uint64 `json:"view_publication"`
44+ Status ports.UIPresentationStatus `json:"status"`
45+}
46+
47+type sessionResponse struct {
48+ LifecycleID string `json:"lifecycle_id"`
49+ SessionName string `json:"session_name"`
50+}
51+
52+type focusResponse struct {
53+ TabID domain.TabStableID `json:"tab_id"`
54+ PaneID domain.PaneStableID `json:"pane_id"`
55+}
56+
57+type cursorResponse struct {
58+ Row int `json:"row"`
59+ Column int `json:"column"`
60+ Visible bool `json:"visible"`
61+ Style int `json:"style"`
62+ StyleSet bool `json:"style_set"`
63+}
64+
65+type colorKind string
66+
67+const (
68+ colorDefault colorKind = "default"
69+ colorIndexed colorKind = "indexed"
70+ colorRGB colorKind = "rgb"
71+)
72+
73+type colorResponse struct {
74+ Kind colorKind `json:"kind"`
75+ Index uint8 `json:"index"`
76+ R uint8 `json:"r"`
77+ G uint8 `json:"g"`
78+ B uint8 `json:"b"`
79+}
80+
81+type styleResponse struct {
82+ Foreground colorResponse `json:"foreground"`
83+ Background colorResponse `json:"background"`
84+ Bold bool `json:"bold"`
85+ Dim bool `json:"dim"`
86+ Italic bool `json:"italic"`
87+ Underline bool `json:"underline"`
88+ UnderlineStyle uint8 `json:"underline_style"`
89+ Blink bool `json:"blink"`
90+ Inverse bool `json:"inverse"`
91+ Strikethrough bool `json:"strikethrough"`
92+}
93+
94+type cellResponse struct {
95+ Text string `json:"text"`
96+ Width int `json:"width"`
97+ Continuation bool `json:"continuation"`
98+ Style styleResponse `json:"style"`
99+}
100+
101+type captureResponse struct {
102+ Revision uint64 `json:"revision"`
103+ Context contextResponse `json:"context"`
104+ Columns int `json:"columns"`
105+ Rows int `json:"rows"`
106+ Cursor cursorResponse `json:"cursor"`
107+ Text *string `json:"text,omitempty"`
108+ Cells []cellResponse `json:"cells,omitempty"`
109+}
110+
111+type actionResponse struct {
112+ ActionID uint64 `json:"action_id"`
113+ Accepted bool `json:"accepted"`
114+ Status ports.UIActionStatus `json:"status"`
115+ Revision uint64 `json:"revision"`
116+ Context contextResponse `json:"context"`
117+}
118+
119+type waitResponse struct {
120+ ActionID uint64 `json:"action_id,omitempty"`
121+ ActionStatus ports.UIActionStatus `json:"action_status,omitempty"`
122+ Revision uint64 `json:"revision"`
123+ Context contextResponse `json:"context"`
124+}
125+
126+func publicContext(value ports.UIContext) contextResponse {
127+ var lifecycleID string
128+ if value.Route.Target.LifecycleID != (domain.SessionLifecycleID{}) {
129+ lifecycleID = value.Route.Target.LifecycleID.String()
130+ }
131+ return contextResponse{Attachment: value.AttachmentHandle, Generation: value.Generation, Session: sessionResponse{LifecycleID: lifecycleID, SessionName: value.Route.Target.SessionName}, Focus: focusResponse{TabID: value.TabID, PaneID: value.FocusedPaneID}, Epoch: value.OutputEpoch, State: value.OutputState, ViewRevision: value.ViewRevision, ViewPublication: value.ViewPublication, Status: value.Status}
132+}
133+
134+func publicColor(value ports.UIColor) colorResponse {
135+ result := colorResponse{Kind: colorDefault}
136+ switch value.Kind {
137+ case ports.UIColorIndexed:
138+ result.Kind = colorIndexed
139+ result.Index = value.Index
140+ case ports.UIColorRGB:
141+ result.Kind = colorRGB
142+ result.R, result.G, result.B = value.R, value.G, value.B
143+ }
144+ return result
145+}
146+
147+func publicCapture(snapshot ports.UISnapshot, format captureFormat) (captureResponse, error) {
148+ result := captureResponse{}
149+ if snapshot.Columns < 1 || snapshot.Rows < 1 || snapshot.Columns > maxColumns || snapshot.Rows > maxRows {
150+ return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
151+ }
152+ if len(snapshot.Cells) != snapshot.Columns*snapshot.Rows {
153+ return result, ports.ErrUIUnavailable
154+ }
155+ if !captureFitsResponse(snapshot, format) {
156+ return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
157+ }
158+ result = captureResponse{Revision: snapshot.Revision, Context: publicContext(snapshot.Context), Columns: snapshot.Columns, Rows: snapshot.Rows, Cursor: cursorResponse{Row: snapshot.Cursor.Row, Column: snapshot.Cursor.Column, Visible: snapshot.Cursor.Visible, Style: snapshot.Cursor.Style, StyleSet: snapshot.Cursor.StyleSet}}
159+ if format == formatText || format == formatBoth {
160+ var text strings.Builder
161+ for row := 0; row < snapshot.Rows; row++ {
162+ if row > 0 {
163+ text.WriteByte('\n')
164+ }
165+ for _, cell := range snapshot.Cells[row*snapshot.Columns : (row+1)*snapshot.Columns] {
166+ if cell.Continuation {
167+ continue
168+ }
169+ if cell.Text == "" {
170+ text.WriteByte(' ')
171+ } else {
172+ text.WriteString(cell.Text)
173+ }
174+ }
175+ }
176+ value := text.String()
177+ result.Text = &value
178+ }
179+ if format == formatCells || format == formatBoth {
180+ result.Cells = make([]cellResponse, len(snapshot.Cells))
181+ for i, cell := range snapshot.Cells {
182+ s := cell.Style
183+ result.Cells[i] = cellResponse{Text: cell.Text, Width: cell.Width, Continuation: cell.Continuation, Style: styleResponse{Foreground: publicColor(s.Foreground), Background: publicColor(s.Background), Bold: s.Bold, Dim: s.Dim, Italic: s.Italic, Underline: s.Underline, UnderlineStyle: s.UnderlineStyle, Blink: s.Blink, Inverse: s.Inverse, Strikethrough: s.Strikethrough}}
184+ }
185+ }
186+ return result, nil
187+}
188+
189+// captureFitsResponse rejects a malicious or corrupted snapshot before
190+// constructing a potentially large response object. The estimate is
191+// intentionally conservative for JSON string escaping and cell style fields.
192+func captureFitsResponse(snapshot ports.UISnapshot, format captureFormat) bool {
193+ const captureOverhead = 4096
194+ const cellOverhead = 320
195+ size := captureOverhead
196+ for _, cell := range snapshot.Cells {
197+ if !utf8.ValidString(cell.Text) {
198+ return false
199+ }
200+ if format == formatCells || format == formatBoth {
201+ if len(cell.Text) > maxResponseBytes || size > maxResponseBytes-len(cell.Text)-cellOverhead {
202+ return false
203+ }
204+ size += len(cell.Text) + cellOverhead
205+ }
206+ if (format == formatText || format == formatBoth) && !cell.Continuation {
207+ if size > maxResponseBytes-len(cell.Text)-1 {
208+ return false
209+ }
210+ size += len(cell.Text) + 1
211+ }
212+ }
213+ return size <= maxResponseBytes
214+}
215+
216+// Never serialize arbitrary error text: lower layers may include endpoints,
217+// input, screen content, or environment. The public code is the safe message.
218+func errorEnvelope(id uint64, err error) envelope {
219+ failure := responseError{Code: ports.UIErrUnavailable}
220+ var uiErr *ports.UIError
221+ if errors.As(err, &uiErr) {
222+ failure.Code = uiErr.Code
223+ failure.Accepted = uiErr.Accepted
224+ failure.ActionID = uiErr.ActionID
225+ } else if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
226+ failure.Code = ports.UIErrTimeout
227+ }
228+ failure.Message = string(failure.Code)
229+ return envelope{Version: apiVersion, ID: id, Error: &failure}
230+}
A internal/adapters/uidriver/response_test.go +88 -0
@@ -0,0 +1,88 @@
1+package uidriver
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCaptureTextCellsAndOwnedContext(t *testing.T) {
14+ snapshot := ports.UISnapshot{Revision: 7, Columns: 4, Rows: 2, Context: ports.UIContext{AttachmentHandle: "a", Generation: 2, Status: ports.UIStatusAttached}, Cells: []ports.UICell{{Text: "界", Width: 2}, {Continuation: true}, {Text: "x", Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}, {Width: 1}}}
15+ snapshot.Cells[2].Style = ports.UIStyle{Foreground: ports.UIColor{Kind: ports.UIColorRGB, R: 1, G: 2, B: 3}, Background: ports.UIColor{Kind: ports.UIColorIndexed, Index: 4}, Inverse: true}
16+ result, err := publicCapture(snapshot, formatBoth)
17+ require.NoError(t, err)
18+ require.Equal(t, "界x \n ", *result.Text)
19+ require.Len(t, result.Cells, 8)
20+ require.True(t, result.Cells[1].Continuation)
21+ require.Equal(t, colorRGB, result.Cells[2].Style.Foreground.Kind)
22+ require.True(t, result.Cells[2].Style.Inverse)
23+ result.Cells[2].Text = "changed"
24+ require.Equal(t, "x", snapshot.Cells[2].Text)
25+ encoded, err := json.Marshal(result)
26+ require.NoError(t, err)
27+ require.Contains(t, string(encoded), `"attachment":"a"`)
28+ require.NotContains(t, string(encoded), "AttachmentHandle")
29+}
30+
31+func TestCaptureRejectsExcessGeometryWithoutResizing(t *testing.T) {
32+ _, err := publicCapture(ports.UISnapshot{Columns: maxColumns + 1, Rows: 1}, formatCells)
33+ var uiErr *ports.UIError
34+ require.ErrorAs(t, err, &uiErr)
35+ require.Equal(t, ports.UIErrCaptureTooLarge, uiErr.Code)
36+}
37+
38+func TestCaptureFitsMaximumViewportForStructuredFormats(t *testing.T) {
39+ snapshot := ports.UISnapshot{Columns: maxColumns, Rows: maxRows, Cells: make([]ports.UICell, maxColumns*maxRows)}
40+ for _, format := range []captureFormat{formatCells, formatBoth} {
41+ t.Run(string(format), func(t *testing.T) {
42+ require.True(t, captureFitsResponse(snapshot, format))
43+ })
44+ }
45+ capture, err := publicCapture(snapshot, formatCells)
46+ require.NoError(t, err)
47+ encoded, err := json.Marshal(capture)
48+ require.NoError(t, err)
49+ require.Less(t, len(encoded), maxResponseBytes)
50+}
51+
52+func TestColorResponseSerializesZeroComponents(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ color colorResponse
56+ want string
57+ }{
58+ {name: "default", color: colorResponse{Kind: colorDefault}, want: `{"kind":"default","index":0,"r":0,"g":0,"b":0}`},
59+ {name: "indexed zero", color: colorResponse{Kind: colorIndexed}, want: `{"kind":"indexed","index":0,"r":0,"g":0,"b":0}`},
60+ {name: "rgb zero", color: colorResponse{Kind: colorRGB}, want: `{"kind":"rgb","index":0,"r":0,"g":0,"b":0}`},
61+ }
62+ for _, test := range tests {
63+ t.Run(test.name, func(t *testing.T) {
64+ encoded, err := json.Marshal(test.color)
65+ require.NoError(t, err)
66+ require.Equal(t, test.want, string(encoded))
67+ })
68+ }
69+}
70+
71+func TestCaptureRejectsOversizedCellBeforeSerialization(t *testing.T) {
72+ snapshot := ports.UISnapshot{Revision: 1, Columns: 1, Rows: 1, Cells: []ports.UICell{{Text: strings.Repeat("x", maxResponseBytes), Width: 1}}}
73+ _, err := publicCapture(snapshot, formatCells)
74+ var uiErr *ports.UIError
75+ require.ErrorAs(t, err, &uiErr)
76+ require.Equal(t, ports.UIErrCaptureTooLarge, uiErr.Code)
77+}
78+
79+func TestErrorResponseNeverExportsUnderlyingMessage(t *testing.T) {
80+ response := errorEnvelope(9, errors.New("private viewport or environment"))
81+ encoded, err := json.Marshal(response)
82+ require.NoError(t, err)
83+ require.NotContains(t, string(encoded), "private")
84+ require.Equal(t, ports.UIErrUnavailable, response.Error.Code)
85+ accepted := errorEnvelope(10, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: 7})
86+ require.True(t, accepted.Error.Accepted)
87+ require.Equal(t, uint64(7), accepted.Error.ActionID)
88+}
A internal/adapters/uidriver/server.go +394 -0
@@ -0,0 +1,394 @@
1+package uidriver
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "sync"
10+
11+ clockadapter "github.com/bnema/vev/internal/adapters/clock"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+const (
16+ responseSlots = 3
17+ errorResponseReserve = 1024
18+)
19+
20+// Server shares attachment-wide connection, action, and serialized-response
21+// limits. Closing a stream cancels its request waiters, never the attachment's
22+// Runner.
23+type Server struct {
24+ service ports.UIService
25+ clock ports.Clock
26+
27+ mu sync.Mutex
28+ connections int
29+ serialized int
30+ actionBusy bool
31+}
32+
33+func New(service ports.UIService, clock ports.Clock) *Server {
34+ if clock == nil {
35+ clock = clockadapter.New()
36+ }
37+ return &Server{service: service, clock: clock}
38+}
39+
40+func (s *Server) reserve(bytes int) bool {
41+ s.mu.Lock()
42+ defer s.mu.Unlock()
43+ // Keep room for one small error in each connection's response slots.
44+ limit := maxSerializedBytes
45+ if bytes > errorResponseReserve {
46+ limit -= maxConnections * responseSlots * errorResponseReserve
47+ }
48+ if s.serialized+bytes > limit {
49+ return false
50+ }
51+ s.serialized += bytes
52+ return true
53+}
54+
55+func (s *Server) release(bytes int) {
56+ s.mu.Lock()
57+ s.serialized -= bytes
58+ s.mu.Unlock()
59+}
60+
61+func (s *Server) admitAction() bool {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ if s.actionBusy {
65+ return false
66+ }
67+ s.actionBusy = true
68+ return true
69+}
70+
71+func (s *Server) finishAction() {
72+ s.mu.Lock()
73+ s.actionBusy = false
74+ s.mu.Unlock()
75+}
76+
77+type queuedResponse struct {
78+ data []byte
79+ reserved int
80+ id uint64
81+ ownsID bool
82+}
83+
84+type actionCompletionTracker interface {
85+ ActionComplete(actionID uint64) <-chan struct{}
86+}
87+
88+func (s *Server) releaseActionWhenDone(ctx context.Context, done <-chan struct{}) {
89+ select {
90+ case <-done:
91+ s.finishAction()
92+ case <-ctx.Done():
93+ s.finishAction()
94+ }
95+}
96+
97+// Serve owns stream until return. Close must interrupt its blocked reads and
98+// writes (Unix sockets and the app's owned stdio pipes satisfy this contract).
99+func (s *Server) Serve(ctx context.Context, stream io.ReadWriteCloser, ready Ready) error {
100+ s.mu.Lock()
101+ if s.connections >= maxConnections {
102+ s.mu.Unlock()
103+ _ = stream.Close()
104+ return &ports.UIError{Code: ports.UIErrBusy}
105+ }
106+ s.connections++
107+ s.mu.Unlock()
108+ defer func() {
109+ s.mu.Lock()
110+ s.connections--
111+ s.mu.Unlock()
112+ }()
113+ defer func() { _ = stream.Close() }()
114+
115+ serving, cancelServing := context.WithCancel(ctx)
116+ defer cancelServing()
117+ requests, cancelRequests := context.WithCancel(serving)
118+ defer cancelRequests()
119+ var closeOnce sync.Once
120+ closeStream := func() { closeOnce.Do(func() { _ = stream.Close() }) }
121+ watcherDone := make(chan struct{})
122+ stopWatcher := make(chan struct{})
123+ go func() {
124+ defer close(watcherDone)
125+ select {
126+ case <-serving.Done():
127+ closeStream()
128+ case <-stopWatcher:
129+ }
130+ }()
131+ defer func() {
132+ close(stopWatcher)
133+ <-watcherDone
134+ }()
135+
136+ queue := make(chan queuedResponse, responseSlots)
137+ slots := make(chan struct{}, responseSlots)
138+ var stateMu sync.Mutex
139+ ids := make(map[uint64]bool)
140+ var waitActive bool
141+
142+ writerDone := make(chan error, 1)
143+ go func() {
144+ var writeErr error
145+ for response := range queue {
146+ if writeErr == nil {
147+ writeErr = s.writeResponse(serving, closeStream, stream, response.data)
148+ }
149+ s.release(response.reserved)
150+ <-slots
151+ if response.ownsID {
152+ stateMu.Lock()
153+ delete(ids, response.id)
154+ stateMu.Unlock()
155+ }
156+ }
157+ writerDone <- writeErr
158+ }()
159+
160+ respond := func(id uint64, ownsID bool, build func() envelope) bool {
161+ select {
162+ case slots <- struct{}{}:
163+ case <-serving.Done():
164+ return false
165+ default:
166+ cancelServing()
167+ closeStream()
168+ return false
169+ }
170+
171+ reserved := maxResponseBytes
172+ var response envelope
173+ if !s.reserve(reserved) {
174+ reserved = errorResponseReserve
175+ if !s.reserve(reserved) {
176+ <-slots
177+ cancelServing()
178+ closeStream()
179+ return false
180+ }
181+ response = errorEnvelope(id, &ports.UIError{Code: ports.UIErrBusy})
182+ } else {
183+ // Build outside all adapter and terminal locks. Capture owns its own
184+ // immutable snapshot and may be evaluated while a wait is blocked.
185+ response = build()
186+ }
187+ data, err := marshalResponse(response)
188+ if err != nil {
189+ response = errorEnvelope(id, &ports.UIError{Code: ports.UIErrCaptureTooLarge})
190+ data, err = marshalResponse(response)
191+ if err != nil {
192+ // The fixed error envelope is deliberately tiny; reaching this
193+ // branch means the process cannot produce a valid response.
194+ s.release(reserved)
195+ <-slots
196+ cancelServing()
197+ closeStream()
198+ return false
199+ }
200+ }
201+ queued := queuedResponse{data: data, reserved: reserved, id: id, ownsID: ownsID}
202+ select {
203+ case queue <- queued:
204+ return true
205+ case <-serving.Done():
206+ s.release(reserved)
207+ <-slots
208+ return false
209+ }
210+ }
211+
212+ if !respond(0, false, func() envelope { return envelope{Version: apiVersion, Result: ready} }) {
213+ cancelServing()
214+ closeStream()
215+ }
216+
217+ var workers sync.WaitGroup
218+ scanner := bufio.NewScanner(stream)
219+ scanner.Buffer(make([]byte, 4096), maxRequestBytes+2)
220+ for scanner.Scan() {
221+ parsed, err := decodeRequest(scanner.Bytes())
222+ if err != nil {
223+ if !respond(parsed.ID, false, func() envelope { return errorEnvelope(parsed.ID, err) }) {
224+ break
225+ }
226+ continue
227+ }
228+
229+ stateMu.Lock()
230+ duplicate := ids[parsed.ID]
231+ if !duplicate {
232+ ids[parsed.ID] = true
233+ }
234+ isAction := parsed.Op == opKeys || parsed.Op == opText
235+ busy := false
236+ if !duplicate && isAction && ready.Control {
237+ busy = !s.admitAction()
238+ }
239+ if !duplicate && parsed.Op == opWait {
240+ busy = waitActive
241+ if !busy {
242+ waitActive = true
243+ }
244+ }
245+ stateMu.Unlock()
246+
247+ if duplicate || busy {
248+ code := ports.UIErrBusy
249+ if duplicate {
250+ code = ports.UIErrInvalidRequest
251+ }
252+ if !respond(parsed.ID, !duplicate, func() envelope {
253+ return errorEnvelope(parsed.ID, &ports.UIError{Code: code})
254+ }) {
255+ break
256+ }
257+ continue
258+ }
259+
260+ execute := func() {
261+ holdAction := false
262+ defer func() {
263+ stateMu.Lock()
264+ if parsed.Op == opWait {
265+ waitActive = false
266+ }
267+ stateMu.Unlock()
268+ if isAction && ready.Control && !holdAction {
269+ s.finishAction()
270+ }
271+ }()
272+ if isAction && !ready.Control {
273+ respond(parsed.ID, true, func() envelope {
274+ return errorEnvelope(parsed.ID, &ports.UIError{Code: ports.UIErrPermissionDenied})
275+ })
276+ return
277+ }
278+ switch parsed.Op {
279+ case opCapture:
280+ respond(parsed.ID, true, func() envelope {
281+ snapshot, err := s.service.Capture(parsed.Attachment)
282+ if err != nil {
283+ return errorEnvelope(parsed.ID, err)
284+ }
285+ capture, err := publicCapture(snapshot, parsed.Format)
286+ if err != nil {
287+ return errorEnvelope(parsed.ID, err)
288+ }
289+ return envelope{Version: apiVersion, ID: parsed.ID, Result: capture}
290+ })
291+ case opKeys, opText:
292+ result, err := s.service.Action(serving, parsed.Action)
293+ if err != nil {
294+ var uiErr *ports.UIError
295+ if errors.As(err, &uiErr) && uiErr.Accepted && uiErr.ActionID != 0 {
296+ if tracker, ok := s.service.(actionCompletionTracker); ok {
297+ if done := tracker.ActionComplete(uiErr.ActionID); done != nil {
298+ holdAction = true
299+ go s.releaseActionWhenDone(serving, done)
300+ }
301+ }
302+ }
303+ }
304+ respond(parsed.ID, true, func() envelope {
305+ if err != nil {
306+ return errorEnvelope(parsed.ID, err)
307+ }
308+ return envelope{Version: apiVersion, ID: parsed.ID, Result: actionResponse{ActionID: result.ActionID, Accepted: result.Accepted, Status: result.Status, Revision: result.Revision, Context: publicContext(result.Context)}}
309+ })
310+ case opWait:
311+ result, err := s.service.Wait(requests, parsed.Wait)
312+ respond(parsed.ID, true, func() envelope {
313+ if err != nil {
314+ return errorEnvelope(parsed.ID, err)
315+ }
316+ return envelope{Version: apiVersion, ID: parsed.ID, Result: waitResponse{ActionID: result.ActionID, ActionStatus: result.ActionStatus, Revision: result.Revision, Context: publicContext(result.Context)}}
317+ })
318+ }
319+ }
320+
321+ // Capture is kept on the read loop so a fast query is never starved by
322+ // an unbounded operation worker. Wait and action must be independent of
323+ // subsequent reads.
324+ if parsed.Op == opCapture {
325+ execute()
326+ } else {
327+ workers.Add(1)
328+ go func() {
329+ defer workers.Done()
330+ execute()
331+ }()
332+ }
333+ if serving.Err() != nil {
334+ break
335+ }
336+ }
337+
338+ readErr := scanner.Err()
339+ if readErr != nil && serving.Err() == nil {
340+ respond(0, false, func() envelope { return errorEnvelope(0, invalidRequest()) })
341+ }
342+ cancelRequests()
343+ workers.Wait()
344+ close(queue)
345+ writeErr := <-writerDone
346+ if ctx.Err() != nil {
347+ return ctx.Err()
348+ }
349+ if writeErr != nil {
350+ return &ports.UIError{Code: ports.UIErrUnavailable}
351+ }
352+ if readErr != nil && !errors.Is(readErr, io.EOF) {
353+ return invalidRequest()
354+ }
355+ return nil
356+}
357+
358+func (s *Server) writeResponse(ctx context.Context, closeStream func(), stream io.Writer, data []byte) error {
359+ timer := s.clock.NewTimer(writeTimeout)
360+ writeDone := make(chan struct{})
361+ deadlineDone := make(chan struct{})
362+ go func() {
363+ defer close(deadlineDone)
364+ select {
365+ case <-timer.C():
366+ closeStream()
367+ case <-writeDone:
368+ case <-ctx.Done():
369+ closeStream()
370+ }
371+ }()
372+ n, err := stream.Write(data)
373+ close(writeDone)
374+ timer.Stop()
375+ <-deadlineDone
376+ if err != nil {
377+ return err
378+ }
379+ if n != len(data) {
380+ return io.ErrShortWrite
381+ }
382+ return nil
383+}
384+
385+func marshalResponse(response envelope) ([]byte, error) {
386+ data, err := json.Marshal(response)
387+ if err != nil {
388+ return nil, err
389+ }
390+ if len(data)+1 > maxResponseBytes {
391+ return nil, errors.New("response exceeds configured size")
392+ }
393+ return append(data, '\n'), nil
394+}
A internal/adapters/uidriver/server_test.go +169 -0
@@ -0,0 +1,169 @@
1+package uidriver
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func testSnapshot() ports.UISnapshot {
20+ return ports.UISnapshot{Revision: 1, Columns: 1, Rows: 1, Context: ports.UIContext{AttachmentHandle: "a", Generation: 1, Status: ports.UIStatusAttached}, Cells: []ports.UICell{{Text: "x", Width: 1}}}
21+}
22+
23+func testReady(control bool) Ready {
24+ return Ready{Attachment: "a", Generation: 1, Control: control, Status: ports.UIStatusAttached}
25+}
26+
27+func readEnvelope(t *testing.T, decoder *json.Decoder) envelope {
28+ t.Helper()
29+ var response envelope
30+ require.NoError(t, decoder.Decode(&response))
31+ return response
32+}
33+
34+func TestServeCaptureProceedsWhileWaitIsPending(t *testing.T) {
35+ service := portsmocks.NewMockUIService(t)
36+ started := make(chan struct{})
37+ release := make(chan struct{})
38+ service.EXPECT().Wait(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIWaitRequest) (ports.UIWaitResult, error) {
39+ close(started)
40+ select {
41+ case <-release:
42+ return ports.UIWaitResult{Revision: 2}, nil
43+ case <-ctx.Done():
44+ return ports.UIWaitResult{}, ctx.Err()
45+ }
46+ }).Once()
47+ service.EXPECT().Capture("a").Return(testSnapshot(), nil).Once()
48+ server := New(service, nil)
49+ host, peer := net.Pipe()
50+ defer func() { _ = peer.Close() }()
51+ require.NoError(t, peer.SetDeadline(time.Now().Add(2*time.Second)))
52+ done := make(chan error, 1)
53+ go func() { done <- server.Serve(context.Background(), host, testReady(true)) }()
54+ decoder := json.NewDecoder(peer)
55+ ready := readEnvelope(t, decoder)
56+ require.Zero(t, ready.ID)
57+ _, err := io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"wait\",\"attachment\":\"a\",\"expect\":{\"status\":\"attached\"}}\n")
58+ require.NoError(t, err)
59+ select {
60+ case <-started:
61+ case <-time.After(time.Second):
62+ t.Fatal("wait not started")
63+ }
64+ for range 2 {
65+ _, err = io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"capture\",\"attachment\":\"a\"}\n")
66+ require.NoError(t, err)
67+ duplicate := readEnvelope(t, decoder)
68+ require.Equal(t, ports.UIErrInvalidRequest, duplicate.Error.Code)
69+ }
70+ _, err = io.WriteString(peer, "{\"version\":1,\"id\":2,\"op\":\"capture\",\"attachment\":\"a\"}\n")
71+ require.NoError(t, err)
72+ capture := readEnvelope(t, decoder)
73+ require.Equal(t, uint64(2), capture.ID)
74+ require.Nil(t, capture.Error)
75+ close(release)
76+ waited := readEnvelope(t, decoder)
77+ require.Equal(t, uint64(1), waited.ID)
78+ require.Nil(t, waited.Error)
79+ require.NoError(t, peer.Close())
80+ select {
81+ case err := <-done:
82+ require.NoError(t, err)
83+ case <-time.After(time.Second):
84+ t.Fatal("connection did not stop")
85+ }
86+ require.Zero(t, server.serialized)
87+}
88+
89+func TestServeReadOnlyRejectsBeforeServiceAction(t *testing.T) {
90+ service := portsmocks.NewMockUIService(t)
91+ host, peer := net.Pipe()
92+ defer func() { _ = peer.Close() }()
93+ require.NoError(t, peer.SetDeadline(time.Now().Add(2*time.Second)))
94+ done := make(chan error, 1)
95+ go func() { done <- New(service, nil).Serve(context.Background(), host, testReady(false)) }()
96+ decoder := json.NewDecoder(peer)
97+ readEnvelope(t, decoder)
98+ _, err := io.WriteString(peer, "{\"version\":1,\"id\":1,\"op\":\"text\",\"attachment\":\"a\",\"generation\":1,\"text\":\"x\"}\n")
99+ require.NoError(t, err)
100+ denied := readEnvelope(t, decoder)
101+ require.Equal(t, ports.UIErrPermissionDenied, denied.Error.Code)
102+ require.False(t, denied.Error.Accepted)
103+ require.NoError(t, peer.Close())
104+ select {
105+ case <-done:
106+ case <-time.After(time.Second):
107+ t.Fatal("connection did not stop")
108+ }
109+ service.AssertNotCalled(t, "Action", mock.Anything, mock.Anything)
110+}
111+
112+type finiteStream struct {
113+ input *strings.Reader
114+ bytes.Buffer
115+}
116+
117+func (s *finiteStream) Read(data []byte) (int, error) { return s.input.Read(data) }
118+func (*finiteStream) Close() error { return nil }
119+
120+func TestServeAcceptsFinalObjectWithoutNewline(t *testing.T) {
121+ service := portsmocks.NewMockUIService(t)
122+ service.EXPECT().Capture("a").Return(testSnapshot(), nil).Once()
123+ stream := &finiteStream{input: strings.NewReader(`{"version":1,"id":1,"op":"capture","attachment":"a"}`)}
124+ server := New(service, nil)
125+ require.NoError(t, server.Serve(context.Background(), stream, testReady(true)))
126+ decoder := json.NewDecoder(bytes.NewReader(stream.Bytes()))
127+ require.Zero(t, readEnvelope(t, decoder).ID)
128+ require.Equal(t, uint64(1), readEnvelope(t, decoder).ID)
129+ require.Zero(t, server.serialized)
130+}
131+
132+func TestServeWriteDeadlineClosesNonreadingStream(t *testing.T) {
133+ clock := portsmocks.NewMockClock(t)
134+ timer := portsmocks.NewMockTimer(t)
135+ expired := make(chan time.Time, 1)
136+ created := make(chan struct{})
137+ clock.EXPECT().NewTimer(writeTimeout).RunAndReturn(func(time.Duration) ports.Timer { close(created); return timer }).Once()
138+ timer.EXPECT().C().Return(expired).Once()
139+ timer.EXPECT().Stop().Return(true).Once()
140+ server := New(nil, clock)
141+ host, peer := net.Pipe()
142+ defer func() { _ = peer.Close() }()
143+ done := make(chan error, 1)
144+ go func() { done <- server.Serve(context.Background(), host, testReady(false)) }()
145+ select {
146+ case <-created:
147+ case <-time.After(time.Second):
148+ t.Fatal("writer did not arm deadline")
149+ }
150+ expired <- time.Time{}
151+ select {
152+ case err := <-done:
153+ require.Error(t, err)
154+ case <-time.After(time.Second):
155+ t.Fatal("nonreading observer retained workers")
156+ }
157+ require.Zero(t, server.serialized)
158+ require.Zero(t, server.connections)
159+}
160+
161+func TestSerializedBudgetKeepsSpaceForBusyErrors(t *testing.T) {
162+ server := New(nil, nil)
163+ for range 2 {
164+ require.True(t, server.reserve(maxResponseBytes))
165+ }
166+ require.False(t, server.reserve(maxResponseBytes))
167+ require.True(t, server.reserve(errorResponseReserve))
168+ require.LessOrEqual(t, server.serialized, maxSerializedBytes)
169+}
A internal/adapters/uidriver/socket.go +264 -0
@@ -0,0 +1,264 @@
1+package uidriver
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "sync"
12+ "time"
13+
14+ "github.com/bnema/vev/pkg/safedir"
15+)
16+
17+const (
18+ unixSocketMode = 0o600
19+ acceptRetryDelay = 10 * time.Millisecond
20+)
21+
22+// UnixEndpoint exposes one already-bound attachment. It never creates or
23+// owns a Runner; closing it only closes observer connections and its socket.
24+type UnixEndpoint struct {
25+ path string
26+ ln *net.UnixListener
27+ server *Server
28+ ready func() Ready
29+
30+ ctx context.Context
31+ cancel context.CancelFunc
32+ done chan struct{}
33+ wg sync.WaitGroup
34+ once sync.Once
35+ file os.FileInfo
36+}
37+
38+// ListenUnix binds a private, per-client UI socket. Existing paths are always
39+// rejected, including stale sockets, so cleanup can never remove a caller's
40+// file.
41+func ListenUnix(path string, server *Server, ready func() Ready) (*UnixEndpoint, error) {
42+ if server == nil {
43+ return nil, errors.New("uidriver: nil server")
44+ }
45+ if err := validateSocketPath(path); err != nil {
46+ return nil, err
47+ }
48+ parent := filepath.Dir(path)
49+ if err := secureSocketParent(parent); err != nil {
50+ return nil, fmt.Errorf("uidriver: secure socket directory: %w", err)
51+ }
52+ if _, err := os.Lstat(path); err == nil {
53+ return nil, errors.New("uidriver: socket path already exists")
54+ } else if !errors.Is(err, os.ErrNotExist) {
55+ return nil, fmt.Errorf("uidriver: inspect socket path: %w", err)
56+ }
57+ address := &net.UnixAddr{Name: path, Net: "unix"}
58+ listener, err := net.ListenUnix("unix", address)
59+ if err != nil {
60+ return nil, fmt.Errorf("uidriver: listen on socket: %w", err)
61+ }
62+ // Never let net.UnixListener unlink the pathname behind our ownership
63+ // check. A caller may replace the path after bind; cleanup must preserve
64+ // that replacement rather than deleting it during Close.
65+ listener.SetUnlinkOnClose(false)
66+ boundInfo, infoErr := os.Lstat(path)
67+ cleanup := func(cleanErr error) (*UnixEndpoint, error) {
68+ _ = listener.Close()
69+ if removeErr := removeOwnedSocket(path, boundInfo); removeErr != nil {
70+ cleanErr = errors.Join(cleanErr, removeErr)
71+ }
72+ return nil, cleanErr
73+ }
74+ if infoErr != nil {
75+ return cleanup(fmt.Errorf("uidriver: stat socket: %w", infoErr))
76+ }
77+ if err := os.Chmod(path, unixSocketMode); err != nil {
78+ return cleanup(fmt.Errorf("uidriver: secure socket: %w", err))
79+ }
80+ info, err := os.Lstat(path)
81+ if err != nil {
82+ return cleanup(fmt.Errorf("uidriver: stat socket: %w", err))
83+ }
84+ ctx, cancel := context.WithCancel(context.Background())
85+ endpoint := &UnixEndpoint{path: path, ln: listener, server: server, ready: ready, ctx: ctx, cancel: cancel, done: make(chan struct{}), file: info}
86+ endpoint.wg.Add(1)
87+ go endpoint.accept()
88+ return endpoint, nil
89+}
90+
91+func secureSocketParent(parent string) error {
92+ for current := parent; ; current = filepath.Dir(current) {
93+ info, err := os.Lstat(current)
94+ if err == nil {
95+ if info.Mode()&os.ModeSymlink != 0 {
96+ return fmt.Errorf("safedir: %s is a symlink", current)
97+ }
98+ if !info.IsDir() {
99+ return fmt.Errorf("safedir: %s is not a directory", current)
100+ }
101+ if current == filepath.Dir(current) {
102+ break
103+ }
104+ continue
105+ }
106+ if !errors.Is(err, os.ErrNotExist) {
107+ return err
108+ }
109+ next := filepath.Dir(current)
110+ if next == current {
111+ break
112+ }
113+ }
114+ return safedir.EnsurePrivate(parent)
115+}
116+
117+func validateSocketPath(path string) error {
118+ if path == "" || len(path) > unixSocketMax || !filepath.IsAbs(path) {
119+ return errors.New("uidriver: socket path must be an absolute path within Unix socket limits")
120+ }
121+ if filepath.Clean(path) != path || path == string(filepath.Separator) || filepath.Base(path) == "." || filepath.Base(path) == ".." {
122+ return errors.New("uidriver: invalid socket path")
123+ }
124+ for i := 0; i < len(path); i++ {
125+ if path[i] == 0 {
126+ return errors.New("uidriver: socket path contains a NUL")
127+ }
128+ }
129+ return nil
130+}
131+
132+// DefaultSocketPath chooses a short private path under the current vev runtime
133+// directory. The handle is already opaque and contains only hex characters.
134+func DefaultSocketPath(runtimeDir, handle string) string {
135+ return filepath.Join(runtimeDir, "ui", handle+".sock")
136+}
137+
138+func (e *UnixEndpoint) accept() {
139+ defer close(e.done)
140+ defer e.wg.Done()
141+ for {
142+ conn, err := e.ln.AcceptUnix()
143+ if err != nil {
144+ if e.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
145+ return
146+ }
147+ select {
148+ case <-e.ctx.Done():
149+ return
150+ case <-time.After(acceptRetryDelay):
151+ }
152+ continue
153+ }
154+ if !peerIsCurrentUser(conn) {
155+ _ = conn.Close()
156+ continue
157+ }
158+ e.wg.Add(1)
159+ go func() {
160+ defer e.wg.Done()
161+ ready := Ready{}
162+ if e.ready != nil {
163+ ready = e.ready()
164+ }
165+ _ = e.server.Serve(e.ctx, conn, ready)
166+ }()
167+ }
168+}
169+
170+// Path is the bound filesystem path.
171+func (e *UnixEndpoint) Path() string { return e.path }
172+
173+func removeOwnedSocket(path string, expected os.FileInfo) error {
174+ if expected == nil {
175+ return nil
176+ }
177+ info, err := os.Lstat(path)
178+ if err != nil {
179+ if errors.Is(err, os.ErrNotExist) {
180+ return nil
181+ }
182+ return err
183+ }
184+ if !os.SameFile(info, expected) {
185+ return nil
186+ }
187+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
188+ return err
189+ }
190+ return nil
191+}
192+
193+// Close stops accepting connections and removes only the socket inode created
194+// by this endpoint.
195+func (e *UnixEndpoint) Close() error {
196+ var closeErr error
197+ e.once.Do(func() {
198+ e.cancel()
199+ if err := e.ln.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
200+ closeErr = err
201+ }
202+ e.wg.Wait()
203+ if err := removeOwnedSocket(e.path, e.file); err != nil && closeErr == nil {
204+ closeErr = err
205+ }
206+ })
207+ return closeErr
208+}
209+
210+// Bridge connects stdio to an opted-in interactive UI socket without creating
211+// another attachment.
212+func Bridge(ctx context.Context, path string, input io.Reader, output io.Writer) error {
213+ if err := validateSocketPath(path); err != nil {
214+ return err
215+ }
216+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path)
217+ if err != nil {
218+ return fmt.Errorf("uidriver: connect socket: %w", err)
219+ }
220+ defer func() { _ = conn.Close() }()
221+ ctx, cancel := context.WithCancel(ctx)
222+ defer cancel()
223+ inputDone := make(chan error, 1)
224+ outputDone := make(chan error, 1)
225+ go func() {
226+ _, copyErr := io.Copy(conn, input)
227+ inputDone <- copyErr
228+ }()
229+ go func() {
230+ _, copyErr := io.Copy(output, conn)
231+ outputDone <- copyErr
232+ }()
233+ select {
234+ case inputErr := <-inputDone:
235+ if inputErr != nil && !errors.Is(inputErr, io.EOF) {
236+ _ = conn.Close()
237+ return inputErr
238+ }
239+ if unixConn, ok := conn.(*net.UnixConn); ok {
240+ _ = unixConn.CloseWrite()
241+ } else {
242+ _ = conn.Close()
243+ }
244+ select {
245+ case outputErr := <-outputDone:
246+ return bridgeCopyError(outputErr)
247+ case <-ctx.Done():
248+ return ctx.Err()
249+ }
250+ case err := <-outputDone:
251+ _ = conn.Close()
252+ return bridgeCopyError(err)
253+ case <-ctx.Done():
254+ _ = conn.Close()
255+ return ctx.Err()
256+ }
257+}
258+
259+func bridgeCopyError(err error) error {
260+ if err == nil || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
261+ return nil
262+ }
263+ return err
264+}
A internal/adapters/uidriver/socket_bsd.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build darwin || freebsd || openbsd || netbsd || dragonfly
2+
3+package uidriver
4+
5+const unixSocketMax = 103
A internal/adapters/uidriver/socket_linux.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build linux
2+
3+package uidriver
4+
5+const unixSocketMax = 107
A internal/adapters/uidriver/socket_other.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !linux && !darwin && !freebsd && !openbsd && !netbsd && !dragonfly
2+
3+package uidriver
4+
5+const unixSocketMax = 107
A internal/adapters/uidriver/socket_test.go +210 -0
@@ -0,0 +1,210 @@
1+package uidriver
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "runtime"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/pkg/safedir"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+func TestValidateSocketPathUsesPlatformLimit(t *testing.T) {
24+ const (
25+ expectedDarwinSocketMax = 103
26+ expectedLinuxSocketMax = 107
27+ )
28+ expectedMax := expectedLinuxSocketMax
29+ switch runtime.GOOS {
30+ case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
31+ expectedMax = expectedDarwinSocketMax
32+ }
33+ tests := []struct {
34+ name string
35+ path string
36+ want bool
37+ }{
38+ {name: "at limit", path: "/" + strings.Repeat("a", expectedMax-1), want: true},
39+ {name: "above limit", path: "/" + strings.Repeat("a", expectedMax), want: false},
40+ }
41+ for _, test := range tests {
42+ t.Run(test.name, func(t *testing.T) {
43+ err := validateSocketPath(test.path)
44+ if test.want {
45+ require.NoError(t, err)
46+ } else {
47+ require.Error(t, err)
48+ }
49+ })
50+ }
51+}
52+
53+func TestListenUnixServesPrivateAttachmentAndCleansOwnedSocket(t *testing.T) {
54+ service := portsmocks.NewMockUIService(t)
55+ service.EXPECT().Capture("attachment").Return(testSnapshot(), nil).Once()
56+ directory := shortDir(t)
57+ path := filepath.Join(directory, "ui.sock")
58+ server := New(service, nil)
59+ endpoint, err := ListenUnix(path, server, func() Ready {
60+ return testReady(false)
61+ })
62+ require.NoError(t, err)
63+ require.Equal(t, os.FileMode(0o600), fileMode(t, path))
64+ defer func() { _ = endpoint.Close() }()
65+
66+ dialer := &net.Dialer{Timeout: time.Second}
67+ conn, err := dialer.DialContext(context.Background(), "unix", path)
68+ require.NoError(t, err)
69+ defer func() { _ = conn.Close() }()
70+ decoder := json.NewDecoder(bufio.NewReader(conn))
71+ var ready envelope
72+ require.NoError(t, decoder.Decode(&ready))
73+ require.Zero(t, ready.ID)
74+ _, err = conn.Write([]byte(`{"version":1,"id":1,"op":"capture","attachment":"attachment"}` + "\n"))
75+ require.NoError(t, err)
76+ var response envelope
77+ require.NoError(t, decoder.Decode(&response))
78+ require.Equal(t, uint64(1), response.ID)
79+ require.Nil(t, response.Error)
80+
81+ require.NoError(t, endpoint.Close())
82+ _, err = os.Lstat(path)
83+ require.ErrorIs(t, err, os.ErrNotExist)
84+}
85+
86+func TestListenUnixRejectsUnsafeParentSymlink(t *testing.T) {
87+ root := shortDir(t)
88+ target := filepath.Join(root, "target")
89+ require.NoError(t, os.Mkdir(target, 0o700))
90+ link := filepath.Join(root, "link")
91+ require.NoError(t, os.Symlink(target, link))
92+ _, err := ListenUnix(filepath.Join(link, "ui.sock"), New(nil, nil), nil)
93+ require.Error(t, err)
94+}
95+
96+func TestListenUnixRejectsExistingPathWithoutRemovingIt(t *testing.T) {
97+ path := filepath.Join(shortDir(t), "ui.sock")
98+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
99+ _, err := ListenUnix(path, New(nil, nil), nil)
100+ require.Error(t, err)
101+ data, readErr := os.ReadFile(path)
102+ require.NoError(t, readErr)
103+ require.Equal(t, []byte("keep"), data)
104+}
105+
106+func TestUnixEndpointClosePreservesReplacementPath(t *testing.T) {
107+ directory := shortDir(t)
108+ path := filepath.Join(directory, "ui.sock")
109+ endpoint, err := ListenUnix(path, New(nil, nil), nil)
110+ require.NoError(t, err)
111+ defer func() { _ = endpoint.Close() }()
112+
113+ replacement := filepath.Join(directory, "replacement")
114+ require.NoError(t, os.Rename(path, replacement))
115+ require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
116+ require.NoError(t, endpoint.Close())
117+
118+ data, err := os.ReadFile(path)
119+ require.NoError(t, err)
120+ require.Equal(t, []byte("keep"), data)
121+ require.NoError(t, os.Remove(replacement))
122+}
123+
124+func TestServeUsesAttachmentWideActionLimitAcrossConnections(t *testing.T) {
125+ service := portsmocks.NewMockUIService(t)
126+ started := make(chan struct{})
127+ release := make(chan struct{})
128+ service.EXPECT().Action(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIActionRequest) (ports.UIActionResult, error) {
129+ close(started)
130+ select {
131+ case <-release:
132+ return ports.UIActionResult{ActionID: 1, Accepted: true, Status: ports.UIActionProcessed}, nil
133+ case <-ctx.Done():
134+ return ports.UIActionResult{}, ctx.Err()
135+ }
136+ }).Once()
137+ server := New(service, nil)
138+ firstHost, firstPeer := net.Pipe()
139+ secondHost, secondPeer := net.Pipe()
140+ defer func() { _ = firstPeer.Close() }()
141+ defer func() { _ = secondPeer.Close() }()
142+ firstDone := make(chan error, 1)
143+ secondDone := make(chan error, 1)
144+ go func() { firstDone <- server.Serve(context.Background(), firstHost, testReady(true)) }()
145+ go func() { secondDone <- server.Serve(context.Background(), secondHost, testReady(true)) }()
146+ firstDecoder := json.NewDecoder(firstPeer)
147+ secondDecoder := json.NewDecoder(secondPeer)
148+ require.Zero(t, readEnvelope(t, firstDecoder).ID)
149+ require.Zero(t, readEnvelope(t, secondDecoder).ID)
150+ request := `{"version":1,"id":1,"op":"text","attachment":"a","generation":1,"text":"x"}` + "\n"
151+ _, err := firstPeer.Write([]byte(request))
152+ require.NoError(t, err)
153+ select {
154+ case <-started:
155+ case <-time.After(time.Second):
156+ t.Fatal("first action did not start")
157+ }
158+ _, err = secondPeer.Write([]byte(request))
159+ require.NoError(t, err)
160+ busy := readEnvelope(t, secondDecoder)
161+ require.Equal(t, ports.UIErrBusy, busy.Error.Code)
162+ close(release)
163+ processed := readEnvelope(t, firstDecoder)
164+ require.Nil(t, processed.Error)
165+ require.NoError(t, firstPeer.Close())
166+ require.NoError(t, secondPeer.Close())
167+ select {
168+ case <-firstDone:
169+ case <-time.After(time.Second):
170+ t.Fatal("first socket server did not stop")
171+ }
172+ select {
173+ case <-secondDone:
174+ case <-time.After(time.Second):
175+ t.Fatal("second socket server did not stop")
176+ }
177+}
178+
179+func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) {
180+ directory := shortDir(t)
181+ path := filepath.Join(directory, "ui.sock")
182+ endpoint, err := ListenUnix(path, New(nil, nil), func() Ready { return testReady(false) })
183+ require.NoError(t, err)
184+ defer func() { _ = endpoint.Close() }()
185+
186+ var output bytes.Buffer
187+ require.NoError(t, Bridge(context.Background(), path, strings.NewReader(""), &output))
188+ decoder := json.NewDecoder(&output)
189+ var ready envelope
190+ require.NoError(t, decoder.Decode(&ready))
191+ require.Zero(t, ready.ID)
192+}
193+
194+func shortDir(t *testing.T) string {
195+ t.Helper()
196+ directory, err := os.MkdirTemp(".", "v")
197+ require.NoError(t, err)
198+ directory, err = filepath.Abs(directory)
199+ require.NoError(t, err)
200+ require.NoError(t, safedir.EnsurePrivate(directory))
201+ t.Cleanup(func() { require.NoError(t, os.RemoveAll(directory)) })
202+ return directory
203+}
204+
205+func fileMode(t *testing.T, path string) os.FileMode {
206+ t.Helper()
207+ info, err := os.Stat(path)
208+ require.NoError(t, err)
209+ return info.Mode().Perm()
210+}
A internal/adapters/uiterm/terminal.go +402 -0
@@ -0,0 +1,402 @@
1+// Package uiterm provides a deterministic VT-backed client terminal for UI
2+// automation and an observation sink for interactive terminals.
3+package uiterm
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "sync"
10+
11+ vt "github.com/bnema/vev-vt"
12+ core "github.com/bnema/vev-vt/core"
13+ "github.com/bnema/vev/internal/adapters/term"
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ MaxColumns = 512
20+ MaxRows = 256
21+ replyQueueDepth = 64
22+)
23+
24+var (
25+ _ ports.Terminal = (*Terminal)(nil)
26+ _ ports.UIState = (*Terminal)(nil)
27+ _ ports.UIOutputTransaction = (*Terminal)(nil)
28+ _ ports.UIObservationSink = (*Terminal)(nil)
29+)
30+
31+type Terminal struct {
32+ mu sync.Mutex
33+
34+ screen *vt.Screen
35+ mirror bool
36+ geometry domain.Geometry
37+ context ports.UIContext
38+ latest ports.UISnapshot
39+ revision uint64
40+ available bool
41+ captureTooLarge bool
42+ dirty bool
43+ closed bool
44+ txDepth int
45+ txFailed bool
46+
47+ changes chan struct{}
48+ replies chan []byte
49+ inR *io.PipeReader
50+ inW *io.PipeWriter
51+ cancel context.CancelFunc
52+ done chan struct{}
53+}
54+
55+func New(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) {
56+ return newTerminal(ctx, geometry, attachmentHandle, true, false)
57+}
58+
59+// NewMirror creates a VT mirror for an existing physical terminal. Physical
60+// terminals remain the sole authority for terminal query responses, so the
61+// mirror deliberately leaves Screen.OnResponse unset. Unlike a headless
62+// terminal, an interactive mirror may represent an oversized viewport; its
63+// captures then report capture_too_large while the physical terminal keeps
64+// operating normally.
65+func NewMirror(ctx context.Context, geometry domain.Geometry, attachmentHandle string) (*Terminal, error) {
66+ return newTerminal(ctx, geometry, attachmentHandle, false, true)
67+}
68+
69+func newTerminal(ctx context.Context, geometry domain.Geometry, attachmentHandle string, deterministicReplies, allowOversized bool) (*Terminal, error) {
70+ geometry = geometry.NormalizePixels()
71+ if geometry.Cols <= 0 || geometry.Rows <= 0 || (!allowOversized && (geometry.Cols > MaxColumns || geometry.Rows > MaxRows)) {
72+ return nil, errors.New("uiterm: geometry outside supported bounds")
73+ }
74+ oversized := geometry.Cols > MaxColumns || geometry.Rows > MaxRows
75+ var screen *vt.Screen
76+ if !oversized {
77+ screen = vt.NewScreen(geometry.Cols, geometry.Rows)
78+ }
79+ ctx, cancel := context.WithCancel(ctx)
80+ inR, inW := io.Pipe()
81+ t := &Terminal{
82+ screen: screen,
83+ mirror: allowOversized,
84+ geometry: geometry,
85+ context: ports.UIContext{AttachmentHandle: attachmentHandle, Generation: 1},
86+ available: true,
87+ captureTooLarge: oversized,
88+ changes: make(chan struct{}, 1),
89+ replies: make(chan []byte, replyQueueDepth),
90+ inR: inR,
91+ inW: inW,
92+ cancel: cancel,
93+ done: make(chan struct{}),
94+ }
95+ if !deterministicReplies || screen == nil {
96+ close(t.done)
97+ return t, nil
98+ }
99+ t.screen.OnResponse = func(response []byte) {
100+ copyResponse := append([]byte(nil), response...)
101+ select {
102+ case t.replies <- copyResponse:
103+ default:
104+ t.available = false
105+ t.signalChange()
106+ }
107+ }
108+ go t.runReplies(ctx)
109+ return t, nil
110+}
111+
112+func (t *Terminal) runReplies(ctx context.Context) {
113+ defer close(t.done)
114+ for {
115+ select {
116+ case <-ctx.Done():
117+ _ = t.inW.CloseWithError(ctx.Err())
118+ return
119+ case response := <-t.replies:
120+ if _, err := t.inW.Write(response); err != nil {
121+ return
122+ }
123+ }
124+ }
125+}
126+
127+func (t *Terminal) EnterRaw() (func() error, error) {
128+ if _, err := t.Write(term.VisualEnterSequence()); err != nil {
129+ return nil, err
130+ }
131+ if err := t.Flush(); err != nil {
132+ return nil, err
133+ }
134+ var once sync.Once
135+ var restoreErr error
136+ return func() error {
137+ once.Do(func() {
138+ _, restoreErr = t.Write(term.VisualRestoreSequence())
139+ if restoreErr == nil {
140+ restoreErr = t.Flush()
141+ }
142+ t.Close()
143+ })
144+ return restoreErr
145+ }, nil
146+}
147+
148+func (t *Terminal) Geometry() (domain.Geometry, error) {
149+ t.mu.Lock()
150+ defer t.mu.Unlock()
151+ if t.closed {
152+ return domain.Geometry{}, io.ErrClosedPipe
153+ }
154+ return t.geometry, nil
155+}
156+
157+func (t *Terminal) ResizeEvents() <-chan domain.Geometry {
158+ ch := make(chan domain.Geometry)
159+ close(ch)
160+ return ch
161+}
162+
163+func (t *Terminal) In() io.Reader { return t.inR }
164+func (t *Terminal) Out() io.Writer { return t }
165+
166+func (t *Terminal) Write(data []byte) (int, error) {
167+ t.mu.Lock()
168+ defer t.mu.Unlock()
169+ if t.closed {
170+ return 0, io.ErrClosedPipe
171+ }
172+ if t.screen != nil {
173+ t.screen.Write(data)
174+ }
175+ t.dirty = true
176+ if !t.available {
177+ return len(data), errors.New("uiterm: terminal response queue overflow")
178+ }
179+ return len(data), nil
180+}
181+
182+func (t *Terminal) Flush() error {
183+ t.mu.Lock()
184+ defer t.mu.Unlock()
185+ if t.closed {
186+ return io.ErrClosedPipe
187+ }
188+ if t.txDepth == 0 {
189+ t.publishLocked()
190+ }
191+ return nil
192+}
193+
194+func (t *Terminal) BeginOutput(context ports.UIContext) {
195+ t.mu.Lock()
196+ defer t.mu.Unlock()
197+ if t.txDepth == 0 {
198+ if context.AttachmentHandle == "" {
199+ context.AttachmentHandle = t.context.AttachmentHandle
200+ }
201+ if context.Generation == 0 {
202+ context.Generation = t.context.Generation
203+ }
204+ t.context = context
205+ t.txFailed = false
206+ }
207+ t.txDepth++
208+}
209+
210+func (t *Terminal) EndOutput(success bool) {
211+ t.mu.Lock()
212+ defer t.mu.Unlock()
213+ if t.txDepth == 0 {
214+ return
215+ }
216+ if !success {
217+ t.txFailed = true
218+ }
219+ t.txDepth--
220+ if t.txDepth != 0 {
221+ return
222+ }
223+ if t.txFailed {
224+ t.available = false
225+ t.signalChange()
226+ return
227+ }
228+ t.publishLocked()
229+}
230+
231+func (t *Terminal) PublishContext(context ports.UIContext) error {
232+ t.mu.Lock()
233+ defer t.mu.Unlock()
234+ if t.closed || !t.available {
235+ return ports.ErrUIUnavailable
236+ }
237+ if context.AttachmentHandle == "" {
238+ context.AttachmentHandle = t.context.AttachmentHandle
239+ }
240+ if context.Generation == 0 {
241+ context.Generation = t.context.Generation
242+ }
243+ t.context = context
244+ t.publishLocked()
245+ return nil
246+}
247+
248+func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
249+ t.mu.Lock()
250+ defer t.mu.Unlock()
251+ if !t.available {
252+ return ports.UISnapshot{}, ports.ErrUIUnavailable
253+ }
254+ if t.captureTooLarge {
255+ return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
256+ }
257+ if t.latest.Revision == 0 {
258+ return ports.UISnapshot{}, ports.ErrUIUnavailable
259+ }
260+ return cloneSnapshot(t.latest), nil
261+}
262+
263+func (t *Terminal) Changes() <-chan struct{} { return t.changes }
264+
265+func (t *Terminal) ObserveTerminalWrite(data []byte) {
266+ _, _ = t.Write(data)
267+}
268+
269+func (t *Terminal) ObserveTerminalFlush() {
270+ _ = t.Flush()
271+}
272+
273+func (t *Terminal) ObserveTerminalResize(geometry domain.Geometry) {
274+ geometry = geometry.NormalizePixels()
275+ t.mu.Lock()
276+ defer t.mu.Unlock()
277+ if t.closed {
278+ return
279+ }
280+ if geometry.Cols <= 0 || geometry.Rows <= 0 {
281+ t.available = false
282+ t.signalChange()
283+ return
284+ }
285+ if geometry.Cols > MaxColumns || geometry.Rows > MaxRows {
286+ t.geometry = geometry
287+ t.captureTooLarge = true
288+ t.dirty = true
289+ t.signalChange()
290+ return
291+ }
292+ if t.screen == nil || t.captureTooLarge && t.mirror {
293+ // A mirror cannot reconstruct the pixels that were emitted while its
294+ // viewport was oversized. Keep capture_too_large rather than exposing
295+ // a new blank or stale screen as current.
296+ t.geometry = geometry
297+ t.signalChange()
298+ return
299+ }
300+ if t.captureTooLarge {
301+ t.captureTooLarge = false
302+ }
303+ if t.geometry == geometry {
304+ return
305+ }
306+ t.geometry = geometry
307+ t.screen.Resize(geometry.Cols, geometry.Rows)
308+ t.dirty = true
309+ if t.txDepth == 0 {
310+ t.publishLocked()
311+ }
312+}
313+
314+func (t *Terminal) InvalidateTerminalObservation() {
315+ t.mu.Lock()
316+ t.available = false
317+ t.mu.Unlock()
318+ t.signalChange()
319+}
320+
321+func (t *Terminal) Close() {
322+ t.mu.Lock()
323+ if t.closed {
324+ t.mu.Unlock()
325+ return
326+ }
327+ t.closed = true
328+ t.available = false
329+ t.cancel()
330+ _ = t.inR.Close()
331+ t.mu.Unlock()
332+ <-t.done
333+ t.signalChange()
334+}
335+
336+func (t *Terminal) publishLocked() {
337+ if !t.available || t.captureTooLarge || t.screen == nil {
338+ return
339+ }
340+ screen := t.screen.Snapshot()
341+ t.revision++
342+ t.latest = convertSnapshot(screen, t.geometry, t.context, t.revision)
343+ t.dirty = false
344+ t.signalChange()
345+}
346+
347+func (t *Terminal) signalChange() {
348+ select {
349+ case t.changes <- struct{}{}:
350+ default:
351+ }
352+}
353+
354+func convertSnapshot(screen vt.ScreenSnapshot, geometry domain.Geometry, context ports.UIContext, revision uint64) ports.UISnapshot {
355+ cells := make([]ports.UICell, 0, screen.Columns()*screen.Rows())
356+ for row := 0; row < screen.Rows(); row++ {
357+ for column := 0; column < screen.Columns(); column++ {
358+ cell := screen.Cell(column, row)
359+ text := ""
360+ width := 0
361+ if !cell.Continuation {
362+ text = string(cell.Rune)
363+ width = core.RuneWidth(cell.Rune)
364+ }
365+ cells = append(cells, ports.UICell{Text: text, Width: width, Continuation: cell.Continuation, Style: convertStyle(cell.Style)})
366+ }
367+ }
368+ cursor := screen.Cursor()
369+ modes := screen.Modes()
370+ return ports.UISnapshot{
371+ Revision: revision, Context: context,
372+ Columns: geometry.Cols, Rows: geometry.Rows,
373+ Cursor: ports.UICursor{Row: cursor.Row, Column: cursor.Col, Visible: cursor.Visible, Style: cursor.Style, StyleSet: cursor.StyleSet},
374+ Cells: cells, AutoWrap: modes.AutoWrap, ApplicationCursor: modes.ApplicationCursor,
375+ }
376+}
377+
378+func convertStyle(style core.Style) ports.UIStyle {
379+ return ports.UIStyle{
380+ Foreground: convertColor(style.Foreground, style.ForegroundRGB, style.HasForegroundRGB),
381+ Background: convertColor(style.Background, style.BackgroundRGB, style.HasBackgroundRGB),
382+ Bold: style.Bold, Dim: style.Attrs&core.AttrDim != 0, Italic: style.Italic,
383+ Underline: style.Attrs&core.AttrUnderline != 0, UnderlineStyle: uint8(style.UnderlineStyle),
384+ Blink: style.Attrs&core.AttrBlink != 0, Inverse: style.Inverse,
385+ Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
386+ }
387+}
388+
389+func convertColor(index int, rgb core.RGB, hasRGB bool) ports.UIColor {
390+ if hasRGB {
391+ return ports.UIColor{Kind: ports.UIColorRGB, R: rgb.R, G: rgb.G, B: rgb.B}
392+ }
393+ if index >= 0 && index <= 255 {
394+ return ports.UIColor{Kind: ports.UIColorIndexed, Index: uint8(index)}
395+ }
396+ return ports.UIColor{Kind: ports.UIColorDefault}
397+}
398+
399+func cloneSnapshot(snapshot ports.UISnapshot) ports.UISnapshot {
400+ snapshot.Cells = append([]ports.UICell(nil), snapshot.Cells...)
401+ return snapshot
402+}
A internal/adapters/uiterm/terminal_test.go +220 -0
@@ -0,0 +1,220 @@
1+package uiterm
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+func TestTerminalPublishesOwnedSnapshotAtFlush(t *testing.T) {
15+ terminal := newTestTerminal(t, 4, 2)
16+ terminal.BeginOutput(ports.UIContext{AttachmentHandle: "attachment", Generation: 2, Status: ports.UIStatusAttached})
17+ if _, err := terminal.Write([]byte("\x1b[1;31;48;2;1;2;3mA界")); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := terminal.Flush(); err != nil {
21+ t.Fatal(err)
22+ }
23+ if _, err := terminal.Snapshot(); err == nil {
24+ t.Fatal("nested flush exposed an output transaction")
25+ }
26+ terminal.EndOutput(true)
27+
28+ snapshot, err := terminal.Snapshot()
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if snapshot.Revision != 1 || snapshot.Columns != 4 || snapshot.Rows != 2 {
33+ t.Fatalf("snapshot metadata = %#v", snapshot)
34+ }
35+ if snapshot.Context.Generation != 2 || snapshot.Cells[0].Text != "A" || !snapshot.Cells[0].Style.Bold {
36+ t.Fatalf("first cell/context = %#v / %#v", snapshot.Cells[0], snapshot.Context)
37+ }
38+ if snapshot.Cells[0].Style.Foreground.Kind != ports.UIColorIndexed || snapshot.Cells[0].Style.Foreground.Index != 1 {
39+ t.Fatalf("foreground = %#v", snapshot.Cells[0].Style.Foreground)
40+ }
41+ if snapshot.Cells[0].Style.Background != (ports.UIColor{Kind: ports.UIColorRGB, R: 1, G: 2, B: 3}) {
42+ t.Fatalf("background = %#v", snapshot.Cells[0].Style.Background)
43+ }
44+ if snapshot.Cells[1].Text != "界" || snapshot.Cells[1].Width != 2 || !snapshot.Cells[2].Continuation {
45+ t.Fatalf("wide cells = %#v %#v", snapshot.Cells[1], snapshot.Cells[2])
46+ }
47+
48+ snapshot.Cells[0].Text = "mutated"
49+ again, err := terminal.Snapshot()
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ if again.Cells[0].Text != "A" {
54+ t.Fatal("caller mutated published snapshot")
55+ }
56+}
57+
58+func TestTerminalEnterRawUsesVisualInitialization(t *testing.T) {
59+ terminal := newTestTerminal(t, 4, 2)
60+ restore, err := terminal.EnterRaw()
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ snapshot, err := terminal.Snapshot()
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if snapshot.AutoWrap || snapshot.Cursor.Visible {
69+ t.Fatalf("enter modes = autoWrap %v cursorVisible %v", snapshot.AutoWrap, snapshot.Cursor.Visible)
70+ }
71+ if err := restore(); err != nil {
72+ t.Fatal(err)
73+ }
74+ if err := restore(); err != nil {
75+ t.Fatalf("second restore = %v", err)
76+ }
77+}
78+
79+func TestTerminalQueuesTerminalResponsesOutsideScreenWrite(t *testing.T) {
80+ terminal := newTestTerminal(t, 4, 2)
81+ if _, err := terminal.Write([]byte("\x1b[?7$p")); err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ result := make(chan string, 1)
86+ go func() {
87+ buffer := make([]byte, len("\x1b[?7;1$y"))
88+ _, err := io.ReadFull(terminal.In(), buffer)
89+ if err != nil {
90+ result <- err.Error()
91+ return
92+ }
93+ result <- string(buffer)
94+ }()
95+ select {
96+ case got := <-result:
97+ if got != "\x1b[?7;1$y" {
98+ t.Fatalf("response = %q", got)
99+ }
100+ case <-time.After(time.Second):
101+ t.Fatal("terminal response was not drained")
102+ }
103+}
104+
105+func TestTerminalPublishesGeometryChangesImmediatelyOutsideOutputTransaction(t *testing.T) {
106+ terminal := newTestTerminal(t, 4, 2)
107+ terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}})
108+ snapshot, err := terminal.Snapshot()
109+ if err != nil {
110+ t.Fatal(err)
111+ }
112+ if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 {
113+ t.Fatalf("resize snapshot = %#v", snapshot)
114+ }
115+}
116+
117+func TestTerminalRecoversAfterOversizedGeometry(t *testing.T) {
118+ terminal := newTestTerminal(t, 4, 2)
119+ terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})
120+ _, err := terminal.Snapshot()
121+ var uiErr *ports.UIError
122+ if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
123+ t.Fatalf("oversized Snapshot() error = %v, want capture_too_large", err)
124+ }
125+
126+ terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}})
127+ snapshot, err := terminal.Snapshot()
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 {
132+ t.Fatalf("recovered resize snapshot = %#v", snapshot)
133+ }
134+}
135+
136+func TestTerminalSignalsCaptureUnavailable(t *testing.T) {
137+ terminal := newTestTerminal(t, 4, 2)
138+ terminal.BeginOutput(ports.UIContext{})
139+ _, _ = terminal.Write([]byte("data"))
140+ terminal.EndOutput(false)
141+ select {
142+ case <-terminal.Changes():
143+ case <-time.After(time.Second):
144+ t.Fatal("capture unavailability did not signal Changes")
145+ }
146+}
147+
148+func TestTerminalFailedTransactionMakesCaptureUnavailable(t *testing.T) {
149+ terminal := newTestTerminal(t, 4, 2)
150+ terminal.BeginOutput(ports.UIContext{})
151+ _, _ = terminal.Write([]byte("data"))
152+ terminal.EndOutput(false)
153+ if _, err := terminal.Snapshot(); !errors.Is(err, ports.ErrUIUnavailable) {
154+ t.Fatalf("Snapshot error = %v", err)
155+ }
156+}
157+
158+func TestMirrorReportsOversizedGeometryWithoutRejectingTheTerminal(t *testing.T) {
159+ ctx := context.Background()
160+ mirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}}, "attachment")
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ defer mirror.Close()
165+
166+ if _, err := mirror.Write([]byte("still interactive")); err != nil {
167+ t.Fatalf("Write() error = %v", err)
168+ }
169+ if err := mirror.Flush(); err != nil {
170+ t.Fatalf("Flush() error = %v", err)
171+ }
172+ _, err = mirror.Snapshot()
173+ var uiErr *ports.UIError
174+ if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
175+ t.Fatalf("Snapshot() error = %v, want capture_too_large", err)
176+ }
177+
178+ validMirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}, "attachment")
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ defer validMirror.Close()
183+ validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})
184+ if _, err := validMirror.Write([]byte("output while oversized")); err != nil {
185+ t.Fatalf("oversized mirror Write() error = %v", err)
186+ }
187+ if err := validMirror.Flush(); err != nil {
188+ t.Fatalf("oversized mirror Flush() error = %v", err)
189+ }
190+ validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}})
191+ _, err = validMirror.Snapshot()
192+ if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
193+ t.Fatalf("recovered mirror Snapshot() error = %v, want capture_too_large", err)
194+ }
195+}
196+
197+func TestTerminalRejectsInvalidGeometry(t *testing.T) {
198+ tests := []domain.Geometry{
199+ {Size: domain.Size{Cols: 0, Rows: 1}},
200+ {Size: domain.Size{Cols: 1, Rows: 0}},
201+ {Size: domain.Size{Cols: MaxColumns + 1, Rows: 1}},
202+ {Size: domain.Size{Cols: 1, Rows: MaxRows + 1}},
203+ }
204+ for _, geometry := range tests {
205+ if terminal, err := New(context.Background(), geometry, "handle"); err == nil {
206+ terminal.Close()
207+ t.Fatalf("New(%#v) succeeded", geometry)
208+ }
209+ }
210+}
211+
212+func newTestTerminal(t *testing.T, columns, rows int) *Terminal {
213+ t.Helper()
214+ terminal, err := New(context.Background(), domain.Geometry{Size: domain.Size{Cols: columns, Rows: rows}}, "attachment")
215+ if err != nil {
216+ t.Fatal(err)
217+ }
218+ t.Cleanup(terminal.Close)
219+ return terminal
220+}
A internal/app/launch_allowlist.go +148 -0
@@ -0,0 +1,148 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "strings"
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/catalogue"
13+)
14+
15+func remoteLaunchAllowlistFromEnv() (map[string]struct{}, bool, error) {
16+ raw, configured := os.LookupEnv(launchAllowedRemoteEndpointsEnv)
17+ if !configured {
18+ return nil, false, nil
19+ }
20+ allowed := make(map[string]struct{})
21+ for _, target := range strings.Split(raw, "\n") {
22+ if target == "" {
23+ continue
24+ }
25+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
26+ return nil, true, errors.New("vev: invalid configured remote endpoint allowlist")
27+ }
28+ allowed[target] = struct{}{}
29+ }
30+ return allowed, true, nil
31+}
32+
33+func allowlistedRemoteEndpoint(allowed map[string]struct{}, target string) bool {
34+ _, ok := allowed[target]
35+ return ok
36+}
37+
38+type allowlistedRemoteHostStore struct {
39+ delegate ports.RemoteHostStore
40+ allowed map[string]struct{}
41+}
42+
43+func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []string, err error) {
44+ pinned, learned, err = s.delegate.Hosts()
45+ return filterRemoteTargets(pinned, s.allowed), filterRemoteTargets(learned, s.allowed), err
46+}
47+
48+func (s allowlistedRemoteHostStore) AddPinned(target string) error {
49+ if !allowlistedRemoteEndpoint(s.allowed, target) {
50+ return endpointNotConfiguredError()
51+ }
52+ return s.delegate.AddPinned(target)
53+}
54+
55+func (s allowlistedRemoteHostStore) RemovePinned(target string) error {
56+ if !allowlistedRemoteEndpoint(s.allowed, target) {
57+ return endpointNotConfiguredError()
58+ }
59+ return s.delegate.RemovePinned(target)
60+}
61+
62+func (s allowlistedRemoteHostStore) Remember(target string) error {
63+ if !allowlistedRemoteEndpoint(s.allowed, target) {
64+ return endpointNotConfiguredError()
65+ }
66+ return s.delegate.Remember(target)
67+}
68+
69+func (s allowlistedRemoteHostStore) Forget(target string) error {
70+ if !allowlistedRemoteEndpoint(s.allowed, target) {
71+ return endpointNotConfiguredError()
72+ }
73+ return s.delegate.Forget(target)
74+}
75+
76+func (s allowlistedRemoteHostStore) Remove(target string) (bool, error) {
77+ if !allowlistedRemoteEndpoint(s.allowed, target) {
78+ return false, endpointNotConfiguredError()
79+ }
80+ return s.delegate.Remove(target)
81+}
82+
83+type allowlistedRemoteCatalogClient struct {
84+ delegate ports.RemoteCatalogClient
85+ allowed map[string]struct{}
86+}
87+
88+func (c allowlistedRemoteCatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
89+ if !allowlistedRemoteEndpoint(c.allowed, target) {
90+ return catalogue.RemoteCatalog{}, endpointNotConfiguredError()
91+ }
92+ return c.delegate.List(ctx, target)
93+}
94+
95+type allowlistedRemotePreviewClient struct {
96+ delegate ports.RemotePreviewClient
97+ allowed map[string]struct{}
98+}
99+
100+func (c allowlistedRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
101+ if !allowlistedRemoteEndpoint(c.allowed, target.Endpoint) {
102+ return protocol.RemotePreview{}, endpointNotConfiguredError()
103+ }
104+ return c.delegate.Preview(ctx, target, width, height)
105+}
106+
107+type allowlistedRemoteCatalogCache struct {
108+ delegate ports.RemoteCatalogCache
109+ allowed map[string]struct{}
110+}
111+
112+func (c allowlistedRemoteCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
113+ entries, err := c.delegate.Load()
114+ if err != nil {
115+ return nil, err
116+ }
117+ filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
118+ for _, entry := range entries {
119+ if allowlistedRemoteEndpoint(c.allowed, entry.Host) {
120+ filtered = append(filtered, entry)
121+ }
122+ }
123+ return filtered, nil
124+}
125+
126+func (c allowlistedRemoteCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
127+ filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
128+ for _, entry := range entries {
129+ if allowlistedRemoteEndpoint(c.allowed, entry.Host) {
130+ filtered = append(filtered, entry)
131+ }
132+ }
133+ return c.delegate.Store(filtered)
134+}
135+
136+func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string {
137+ filtered := make([]string, 0, len(targets))
138+ for _, target := range targets {
139+ if allowlistedRemoteEndpoint(allowed, target) {
140+ filtered = append(filtered, target)
141+ }
142+ }
143+ return filtered
144+}
145+
146+func endpointNotConfiguredError() error {
147+ return &ports.UIError{Code: ports.UIErrEndpointNotConfigured}
148+}
M internal/app/remote_final_acceptance_test.go +5 -0
@@ -82,6 +82,11 @@ func serveAcceptanceRemote(tr wire.Transport, target string, handoff protocol.At
82 }82 }
83 payload, err := wire.MarshalOutput(protocol.Output{83 payload, err := wire.MarshalOutput(protocol.Output{
84 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,84 Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
85+ Context: &protocol.ViewContext{
86+ Publication: 1,
87+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "remote-fixture"}},
88+ TabID: "tab-1", FocusedPaneID: "pane-1",
89+ },
85 })90 })
86 if err != nil {91 if err != nil {
87 return92 return
M internal/app/run.go +159 -43
@@ -41,7 +41,6 @@ import (
41 "github.com/bnema/vev/internal/adapters/shellcmd"41 "github.com/bnema/vev/internal/adapters/shellcmd"
42 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"42 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
43 "github.com/bnema/vev/internal/adapters/sshstdio"43 "github.com/bnema/vev/internal/adapters/sshstdio"
44- "github.com/bnema/vev/internal/adapters/term"
45 "github.com/bnema/vev/internal/domain"44 "github.com/bnema/vev/internal/domain"
46 "github.com/bnema/vev/internal/logging"45 "github.com/bnema/vev/internal/logging"
47 "github.com/bnema/vev/internal/persist"46 "github.com/bnema/vev/internal/persist"
@@ -71,6 +70,8 @@ const (
71 kindUDPBootstrap70 kindUDPBootstrap
72 kindUDPProxy71 kindUDPProxy
73 kindRemotePreview72 kindRemotePreview
73+ kindUIDriver
74+ kindUIRemoteCleanup
74 kindHelp75 kindHelp
75 kindVersion76 kindVersion
76 )77 )
@@ -90,6 +91,10 @@ type command struct {
90 killDaemon bool91 killDaemon bool
91 cmd cmdInvocation92 cmd cmdInvocation
92 remotePreviewPayload string93 remotePreviewPayload string
94+ uiDriver uiDriverOptions
95+ uiObserve bool
96+ uiControl bool
97+ uiSocket string
93 }98 }
94 99
95 // usageError is a user-facing argument error; the app prints it (with usage)100 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -120,6 +125,10 @@ usage:
120 vev kill --all kill all sessions and stop the daemon125 vev kill --all kill all sessions and stop the daemon
121 vev kill --daemon stop the active vev daemon126 vev kill --daemon stop the active vev daemon
122 vev cmd <command> run a control command (vev cmd --help)127 vev cmd <command> run a control command (vev cmd --help)
128+ vev --ui-observe expose passive observation for this interactive client
129+ (optional: --ui-socket PATH)
130+ vev --ui-control expose observation and input control for this client
131+ (optional: --ui-socket PATH)
123 vev --help show this help132 vev --help show this help
124 vev --version show version`133 vev --version show version`
125 134
@@ -162,6 +171,46 @@ func parseArgs(args []string) (command, error) {
162 if len(args) == 0 {171 if len(args) == 0 {
163 return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil172 return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil
164 }173 }
174+ if args[0] == "--ui-driver" {
175+ options, err := parseUIDriverArgs(args[1:])
176+ if err != nil {
177+ return command{}, err
178+ }
179+ return command{kind: kindUIDriver, uiDriver: options}, nil
180+ }
181+
182+ var uiObserve, uiControl bool
183+ var uiSocket string
184+ for len(args) > 0 {
185+ switch args[0] {
186+ case "--ui-observe":
187+ uiObserve = true
188+ case "--ui-control":
189+ uiControl = true
190+ case "--ui-socket":
191+ if len(args) < 2 || args[1] == "" {
192+ return command{}, usagef("`--ui-socket` requires a path")
193+ }
194+ uiSocket = args[1]
195+ args = args[2:]
196+ continue
197+ default:
198+ goto parsedUIFlags
199+ }
200+ args = args[1:]
201+ }
202+parsedUIFlags:
203+ interactive, err := parseInteractiveUIFlags(nil, uiObserve, uiControl, uiSocket)
204+ if err != nil {
205+ return command{}, err
206+ }
207+ uiObserve, uiControl, uiSocket = interactive.observe, interactive.control, interactive.socket
208+ if len(args) == 0 {
209+ return command{kind: kindAttach, intent: protocol.IntentEphemeral, uiObserve: uiObserve, uiControl: uiControl, uiSocket: uiSocket}, nil
210+ }
211+ if (uiObserve || uiControl || uiSocket != "") && args[0] != "new" && args[0] != "attach" && args[0] != "a" {
212+ return command{}, usagef("UI flags are only valid for an attach command")
213+ }
165 214
166 switch args[0] {215 switch args[0] {
167 case "--daemon":216 case "--daemon":
@@ -188,23 +237,30 @@ func parseArgs(args []string) (command, error) {
188 return command{}, usagef("`_remote-preview` requires one encoded request")237 return command{}, usagef("`_remote-preview` requires one encoded request")
189 }238 }
190 return command{kind: kindRemotePreview, remotePreviewPayload: args[1]}, nil239 return command{kind: kindRemotePreview, remotePreviewPayload: args[1]}, nil
240+ case uiRemoteCleanupCommand:
241+ if len(args) != 1 {
242+ return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand)
243+ }
244+ return command{kind: kindUIRemoteCleanup}, nil
191 case "new":245 case "new":
192 if len(args) < 2 || args[1] == "" {246 if len(args) < 2 || args[1] == "" {
193 return command{}, usagef("`new` requires a session name")247 return command{}, usagef("`new` requires a session name")
194 }248 }
195- if len(args) > 2 {249+ interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket)
196- return command{}, usagef("`new` does not support command overrides")250+ if err != nil {
251+ return command{}, err
197 }252 }
198 if err := domain.ValidateSessionName(args[1]); err != nil {253 if err := domain.ValidateSessionName(args[1]); err != nil {
199 return command{}, err254 return command{}, err
200 }255 }
201- return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1]}, nil256+ return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1], uiObserve: interactive.observe, uiControl: interactive.control, uiSocket: interactive.socket}, nil
202 case "attach", "a":257 case "attach", "a":
203 if len(args) < 2 || args[1] == "" {258 if len(args) < 2 || args[1] == "" {
204 return command{}, usagef("`attach` requires a session name")259 return command{}, usagef("`attach` requires a session name")
205 }260 }
206- if len(args) > 2 {261+ interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket)
207- return command{}, usagef("`attach` accepts exactly one session name or remote target")262+ if err != nil {
263+ return command{}, err
208 }264 }
209 cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]}265 cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]}
210 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {266 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
@@ -222,6 +278,9 @@ func parseArgs(args []string) (command, error) {
222 cmd.intent = protocol.IntentEphemeral278 cmd.intent = protocol.IntentEphemeral
223 }279 }
224 }280 }
281+ cmd.uiObserve = interactive.observe
282+ cmd.uiControl = interactive.control
283+ cmd.uiSocket = interactive.socket
225 return cmd, nil284 return cmd, nil
226 case "ls", "list":285 case "ls", "list":
227 return parseListArgs(args[1:])286 return parseListArgs(args[1:])
@@ -284,6 +343,10 @@ func dispatch(ctx context.Context, cmd command) error {
284 return runUDPProxy(ctx, os.Stdout)343 return runUDPProxy(ctx, os.Stdout)
285 case kindRemotePreview:344 case kindRemotePreview:
286 return runRemotePreview(ctx, cmd.remotePreviewPayload)345 return runRemotePreview(ctx, cmd.remotePreviewPayload)
346+ case kindUIRemoteCleanup:
347+ return runUIRemoteCleanup(ctx)
348+ case kindUIDriver:
349+ return runUIDriver(ctx, cmd.uiDriver)
287 case kindList:350 case kindList:
288 return runList(ctx, cmd)351 return runList(ctx, cmd)
289 case kindHost:352 case kindHost:
@@ -293,7 +356,7 @@ func dispatch(ctx context.Context, cmd command) error {
293 case kindCmd:356 case kindCmd:
294 return runCmd(ctx, cmd.cmd)357 return runCmd(ctx, cmd.cmd)
295 case kindAttach:358 case kindAttach:
296- return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)359+ return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket})
297 default:360 default:
298 return usagef("unhandled command")361 return usagef("unhandled command")
299 }362 }
@@ -637,7 +700,16 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
637 if observerCloser != nil {700 if observerCloser != nil {
638 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()701 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
639 }702 }
640- remoteDiscoveryOpt, err := remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport))703+ allowedRemoteEndpoints, allowlistConfigured, err := remoteLaunchAllowlistFromEnv()
704+ if err != nil {
705+ return err
706+ }
707+ var remoteDiscoveryOpt daemon.Option
708+ if allowlistConfigured {
709+ remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), allowedRemoteEndpoints)
710+ } else {
711+ remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport))
712+ }
641 if err != nil {713 if err != nil {
642 return err714 return err
643 }715 }
@@ -785,7 +857,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
785 })857 })
786 }858 }
787 859
788-const envRemoteTransport = "VEV_REMOTE_TRANSPORT"860+const (
861+ envRemoteTransport = "VEV_REMOTE_TRANSPORT"
862+ uiRemoteCleanupCommand = "_ui-cleanup"
863+)
789 864
790 func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }865 func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
791 866
@@ -802,6 +877,12 @@ type runAttachDeps struct {
802 runClient runClientFunc877 runClient runClientFunc
803 createDetached func(context.Context, string) error878 createDetached func(context.Context, string) error
804 runtimeObserver ports.SerializedRuntimeObserver879 runtimeObserver ports.SerializedRuntimeObserver
880+ ui *client.UI
881+ terminal func() ports.Terminal
882+ clock func() ports.Clock
883+ disableCapabilityProbe bool
884+ localEnvironment []string
885+ remoteEnvironment func(string) []string
805 // clipboard reads a clipboard image on a remote route's Ctrl+V.886 // clipboard reads a clipboard image on a remote route's Ctrl+V.
806 // The client retains it across local-to-remote handoffs and only enables887 // The client retains it across local-to-remote handoffs and only enables
807 // interception while the active route is remote.888 // interception while the active route is remote.
@@ -845,17 +926,24 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error {
845 926
846 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from927 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
847 // the same validated transport selection used by direct remote attach.928 // the same validated transport selection used by direct remote attach.
848-func remoteDiscoveryDaemonOption(stateDir, transport string) (daemon.Option, error) {929+func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[string]struct{}) (daemon.Option, error) {
849 _, err := remoteTransportModeFromEnv(transport)930 _, err := remoteTransportModeFromEnv(transport)
850 if err != nil {931 if err != nil {
851 return nil, err932 return nil, err
852 }933 }
853- discovery := daemon.WithRemoteDiscovery(934+ store := ports.RemoteHostStore(newRemoteHostStore(remoteadapter.HostStorePath(stateDir)))
854- newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),935+ catalog := ports.RemoteCatalogClient(newRemoteCatalogClient())
855- newRemoteCatalogClient(),936+ cache := ports.RemoteCatalogCache(newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)))
856- newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),937+ previewClient := ports.RemotePreviewClient(newRemotePreviewClient())
857- )938+ if len(allowlists) > 0 {
858- preview := daemon.WithRemotePreview(newRemotePreviewClient())939+ allowed := allowlists[0]
940+ store = allowlistedRemoteHostStore{delegate: store, allowed: allowed}
941+ catalog = allowlistedRemoteCatalogClient{delegate: catalog, allowed: allowed}
942+ cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed}
943+ previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed}
944+ }
945+ discovery := daemon.WithRemoteDiscovery(store, catalog, cache)
946+ preview := daemon.WithRemotePreview(previewClient)
859 return func(d *daemon.Daemon) {947 return func(d *daemon.Daemon) {
860 discovery(d)948 discovery(d)
861 preview(d)949 preview(d)
@@ -932,6 +1020,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
932 Intent: target.Intent,1020 Intent: target.Intent,
933 SessionName: target.Session,1021 SessionName: target.Session,
934 Remote: true,1022 Remote: true,
1023+ Environment: clientEnvironment(deps.remoteEnvironment, target.Endpoint),
935 Origin: protocol.RouteOriginDiscovery,1024 Origin: protocol.RouteOriginDiscovery,
936 OriginKey: target.Endpoint,1025 OriginKey: target.Endpoint,
937 RemoteTarget: selection,1026 RemoteTarget: selection,
@@ -952,21 +1041,24 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
952 return dialErr1041 return dialErr
953 }1042 }
954 err = runClient(ctx, client.Dependencies{1043 err = runClient(ctx, client.Dependencies{
955- Dialer: sessionwire.NewClientDialer(dialer),1044+ Dialer: sessionwire.NewClientDialer(dialer),
956- Terminal: term.New(),1045+ Terminal: clientTerminal(deps),
957- Clock: clock.New(),1046+ Clock: clientClock(deps),
958- Clipboard: deps.clipboard,1047+ DisableCapabilityProbe: deps.disableCapabilityProbe,
959- Logger: log,1048+ UI: deps.ui,
960- RuntimeObserver: deps.runtimeObserver,1049+ Clipboard: deps.clipboard,
961- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),1050+ Logger: log,
962- AttachHandoff: handoff,1051+ RuntimeObserver: deps.runtimeObserver,
963- Remote: true,1052+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
964- Origin: protocol.RouteOriginRemote,1053+ AttachHandoff: handoff,
965- OriginKey: remoteTarget,1054+ Remote: true,
1055+ Origin: protocol.RouteOriginRemote,
1056+ OriginKey: remoteTarget,
966 }, client.AttachRequest{1057 }, client.AttachRequest{
967 Intent: intent,1058 Intent: intent,
968 SessionName: name,1059 SessionName: name,
969 Remote: true,1060 Remote: true,
1061+ Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget),
970 Origin: routeOrigin,1062 Origin: routeOrigin,
971 OriginKey: routeOriginKey,1063 OriginKey: routeOriginKey,
972 RemoteTarget: remoteSelection,1064 RemoteTarget: remoteSelection,
@@ -978,17 +1070,19 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
978 log.Info("attaching to local session", "intent", intent, "name", name)1070 log.Info("attaching to local session", "intent", intent, "name", name)
979 }1071 }
980 err = runClient(ctx, client.Dependencies{1072 err = runClient(ctx, client.Dependencies{
981- Dialer: sessionwire.NewClientDialer(localDialer()),1073+ Dialer: sessionwire.NewClientDialer(localDialer()),
982- Terminal: term.New(),1074+ Terminal: clientTerminal(deps),
983- Clock: clock.New(),1075+ Clock: clientClock(deps),
984- Clipboard: deps.clipboard,1076+ DisableCapabilityProbe: deps.disableCapabilityProbe,
985- Logger: log,1077+ UI: deps.ui,
986- RuntimeObserver: deps.runtimeObserver,1078+ Clipboard: deps.clipboard,
987- AttachHandoff: handoff,1079+ Logger: log,
988- Remote: false,1080+ RuntimeObserver: deps.runtimeObserver,
989- Origin: protocol.RouteOriginLocal,1081+ AttachHandoff: handoff,
990- OriginKey: "local",1082+ Remote: false,
991- }, client.AttachRequest{Intent: intent, SessionName: name, Origin: protocol.RouteOriginLocal, OriginKey: "local"})1083+ Origin: protocol.RouteOriginLocal,
1084+ OriginKey: "local",
1085+ }, client.AttachRequest{Intent: intent, SessionName: name, Environment: append([]string(nil), deps.localEnvironment...), Origin: protocol.RouteOriginLocal, OriginKey: "local"})
992 }1086 }
993 1087
994 var handoffErr *client.AttachTargetError1088 var handoffErr *client.AttachTargetError
@@ -1029,9 +1123,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
1029 1123
1030 const daemonStopTimeout = 2 * time.Second1124 const daemonStopTimeout = 2 * time.Second
1031 1125
1126+var errDaemonNotRunning = errors.New("vev: no daemon running")
1127+
1032 type localDaemonDialer struct {1128 type localDaemonDialer struct {
1033- dir string1129+ dir string
1034- observer ports.SerializedRuntimeObserver1130+ observer ports.SerializedRuntimeObserver
1131+ executable string
1132+ environment []string
1035 }1133 }
1036 1134
1037 func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {1135 func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
@@ -1041,7 +1139,11 @@ func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
1041 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))1139 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
1042 }1140 }
1043 }1141 }
1044- return ensureDaemonWithLifecycle(ctx, d.dir, dial, realSpawn, defaultBackoff)1142+ spawn := realSpawn
1143+ if d.executable != "" {
1144+ spawn = func() error { return spawnDaemonWithEnvironment(d.executable, d.environment) }
1145+ }
1146+ return ensureDaemonWithLifecycle(ctx, d.dir, dial, spawn, defaultBackoff)
1045 }1147 }
1046 1148
1047 func detachedLocalHello(name, cwd string) protocol.Hello {1149 func detachedLocalHello(name, cwd string) protocol.Hello {
@@ -1107,6 +1209,20 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
1107 }1209 }
1108 }1210 }
1109 1211
1212+func runUIRemoteCleanup(ctx context.Context) error {
1213+ root := os.Getenv("VEV_ENV_ROOT")
1214+ if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) {
1215+ return errors.New("vev: remote UI cleanup requires a private absolute launch root")
1216+ }
1217+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") {
1218+ return errors.New("vev: remote UI cleanup requires the launch runtime directory")
1219+ }
1220+ if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) {
1221+ return err
1222+ }
1223+ return nil
1224+}
1225+
1110 func requestDaemonStop(ctx context.Context) error {1226 func requestDaemonStop(ctx context.Context) error {
1111 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)1227 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1112 defer cancel()1228 defer cancel()
@@ -1116,7 +1232,7 @@ func requestDaemonStop(ctx context.Context) error {
1116 return fmt.Errorf("vev: stopping daemon: %w", err)1232 return fmt.Errorf("vev: stopping daemon: %w", err)
1117 }1233 }
1118 if owner != nil {1234 if owner != nil {
1119- return errors.Join(errors.New("vev: no daemon running"), owner.Release())1235+ return errors.Join(errDaemonNotRunning, owner.Release())
1120 }1236 }
1121 defer func() { _ = transport.Close() }()1237 defer func() { _ = transport.Close() }()
1122 if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillDaemon})}); err != nil {1238 if err := transport.Send(wire.Frame{Type: wire.MsgKill, Payload: wire.MarshalKill(protocol.Kill{Scope: protocol.KillDaemon})}); err != nil {
@@ -1585,7 +1701,7 @@ func runKill(ctx context.Context, name string, all, daemon bool) (retErr error)
1585 printKillSuccess(name, all, daemon)1701 printKillSuccess(name, all, daemon)
1586 return nil1702 return nil
1587 }1703 }
1588- return fmt.Errorf("vev: no daemon running")1704+ return errDaemonNotRunning
1589 }1705 }
1590 defer func() { _ = transport.Close() }()1706 defer func() { _ = transport.Close() }()
1591 1707
M internal/app/spawn.go +30 -0
@@ -298,6 +298,36 @@ func withoutPerformanceTraceEnv(env []string) []string {
298 return filtered298 return filtered
299 }299 }
300 300
301+// spawnDaemonWithEnvironment launches an explicitly selected daemon binary
302+// with the complete environment owned by a launch configuration. It retains
303+// the same double-fork boundary as realSpawn without invoking a shell or
304+// inheriting ambient variables.
305+func spawnDaemonWithEnvironment(executable string, environment []string) error {
306+ if executable == "" {
307+ return errors.New("vev: missing launch executable")
308+ }
309+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
310+ if err != nil {
311+ return fmt.Errorf("opening %s: %w", os.DevNull, err)
312+ }
313+ defer func() { _ = devNull.Close() }()
314+
315+ var stderr bytes.Buffer
316+ cmd := exec.Command(executable, "--daemon-launcher")
317+ cmd.Env = append([]string(nil), environment...)
318+ cmd.Dir = platform.DirOrHome("")
319+ cmd.Stdin = devNull
320+ cmd.Stdout = devNull
321+ cmd.Stderr = &stderr
322+ if err := cmd.Run(); err != nil {
323+ if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 {
324+ return fmt.Errorf("%w: %s", err, detail)
325+ }
326+ return err
327+ }
328+ return nil
329+}
330+
301 // runDaemonLauncher is the intermediate half of the double-fork. It starts331 // runDaemonLauncher is the intermediate half of the double-fork. It starts
302 // the long-lived daemon in a new session and exits immediately; realSpawn332 // the long-lived daemon in a new session and exits immediately; realSpawn
303 // waits for this exit so the daemon is no longer descended from the client.333 // waits for this exit so the daemon is no longer descended from the client.
A internal/app/ui_driver.go +1006 -0
@@ -0,0 +1,1006 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "crypto/rand"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "log/slog"
13+ "os"
14+ "os/signal"
15+ "path/filepath"
16+ "sort"
17+ "strconv"
18+ "strings"
19+ "sync"
20+ "syscall"
21+ "time"
22+ "unicode/utf8"
23+
24+ "github.com/bnema/vev/internal/adapters/clipboard"
25+ "github.com/bnema/vev/internal/adapters/clock"
26+ "github.com/bnema/vev/internal/adapters/ipc"
27+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
28+ "github.com/bnema/vev/internal/adapters/sshstdio"
29+ "github.com/bnema/vev/internal/adapters/term"
30+ "github.com/bnema/vev/internal/adapters/uidriver"
31+ "github.com/bnema/vev/internal/adapters/uiterm"
32+ "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/logging"
34+ "github.com/bnema/vev/internal/platform"
35+ "github.com/bnema/vev/internal/ports"
36+ "github.com/bnema/vev/internal/protocol"
37+ "github.com/bnema/vev/internal/protocol/wire"
38+ "github.com/bnema/vev/internal/usecase/client"
39+ "github.com/bnema/vev/pkg/safedir"
40+)
41+
42+const (
43+ uiDriverDefaultColumns = 80
44+ uiDriverDefaultRows = 24
45+ launchConfigVersion = 1
46+ launchConfigMaxBytes = 64 << 10
47+ launchConfigMaxDepth = 8
48+ launchAllowedRemoteEndpointsEnv = "VEV_UI_DRIVER_ALLOWED_REMOTE_ENDPOINTS"
49+ uiDriverCleanupTimeout = 10 * time.Second
50+)
51+
52+type uiDriverOptions struct {
53+ socket string
54+ session string
55+ cols int
56+ rows int
57+ remote string
58+ launchConfig string
59+}
60+
61+type interactiveUIOptions struct {
62+ observe bool
63+ control bool
64+ socket string
65+}
66+
67+func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) {
68+ options := interactiveUIOptions{observe: observe, control: control, socket: socket}
69+ for len(args) > 0 {
70+ switch args[0] {
71+ case "--ui-observe":
72+ options.observe = true
73+ args = args[1:]
74+ case "--ui-control":
75+ options.control = true
76+ args = args[1:]
77+ case "--ui-socket":
78+ if len(args) < 2 || args[1] == "" {
79+ return interactiveUIOptions{}, usagef("`--ui-socket` requires a path")
80+ }
81+ options.socket = args[1]
82+ args = args[2:]
83+ default:
84+ return interactiveUIOptions{}, usagef("unknown attach option %q", args[0])
85+ }
86+ }
87+ if options.socket != "" && !options.observe && !options.control {
88+ return interactiveUIOptions{}, usagef("`--ui-socket` requires `--ui-observe` or `--ui-control`")
89+ }
90+ if options.socket != "" && (!filepath.IsAbs(options.socket) || strings.IndexByte(options.socket, 0) >= 0) {
91+ return interactiveUIOptions{}, usagef("`--ui-socket` requires an absolute path")
92+ }
93+ return options, nil
94+}
95+
96+func parseUIDriverArgs(args []string) (uiDriverOptions, error) {
97+ options := uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}
98+ var socketSet, sessionSet, colsSet, rowsSet, remoteSet, launchSet bool
99+ for len(args) > 0 {
100+ name := args[0]
101+ if name == "--help" || name == "-h" {
102+ return uiDriverOptions{}, usagef("`--ui-driver` options: --session NAME --cols N --rows N --remote ENDPOINT --launch-config PATH, or --socket PATH")
103+ }
104+ if !strings.HasPrefix(name, "--") {
105+ return uiDriverOptions{}, usagef("`--ui-driver` does not accept positional arguments")
106+ }
107+ args = args[1:]
108+ value := func(flag string) (string, error) {
109+ if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "--") {
110+ return "", usagef("`--ui-driver %s` requires a value", flag)
111+ }
112+ value := args[0]
113+ args = args[1:]
114+ return value, nil
115+ }
116+ switch name {
117+ case "--socket":
118+ if socketSet {
119+ return uiDriverOptions{}, usagef("duplicate `--socket`")
120+ }
121+ value, err := value("--socket")
122+ if err != nil {
123+ return uiDriverOptions{}, err
124+ }
125+ if !filepath.IsAbs(value) || strings.IndexByte(value, 0) >= 0 {
126+ return uiDriverOptions{}, usagef("`--socket` requires an absolute path")
127+ }
128+ options.socket, socketSet = value, true
129+ case "--session":
130+ if sessionSet {
131+ return uiDriverOptions{}, usagef("duplicate `--session`")
132+ }
133+ value, err := value("--session")
134+ if err != nil {
135+ return uiDriverOptions{}, err
136+ }
137+ if err := domain.ValidateSessionName(value); err != nil {
138+ return uiDriverOptions{}, err
139+ }
140+ options.session, sessionSet = value, true
141+ case "--cols", "--rows":
142+ value, err := value(name)
143+ if err != nil {
144+ return uiDriverOptions{}, err
145+ }
146+ number, err := strconv.Atoi(value)
147+ if err != nil || number <= 0 {
148+ return uiDriverOptions{}, usagef("`%s` requires a positive integer", name)
149+ }
150+ if name == "--cols" {
151+ if colsSet {
152+ return uiDriverOptions{}, usagef("duplicate `--cols`")
153+ }
154+ options.cols, colsSet = number, true
155+ } else {
156+ if rowsSet {
157+ return uiDriverOptions{}, usagef("duplicate `--rows`")
158+ }
159+ options.rows, rowsSet = number, true
160+ }
161+ case "--remote":
162+ if remoteSet {
163+ return uiDriverOptions{}, usagef("duplicate `--remote`")
164+ }
165+ value, err := value("--remote")
166+ if err != nil {
167+ return uiDriverOptions{}, err
168+ }
169+ if err := domain.ValidateRemoteHostTarget(value); err != nil {
170+ return uiDriverOptions{}, err
171+ }
172+ options.remote, remoteSet = value, true
173+ case "--launch-config":
174+ if launchSet {
175+ return uiDriverOptions{}, usagef("duplicate `--launch-config`")
176+ }
177+ value, err := value("--launch-config")
178+ if err != nil {
179+ return uiDriverOptions{}, err
180+ }
181+ if strings.IndexByte(value, 0) >= 0 {
182+ return uiDriverOptions{}, usagef("`--launch-config` contains a NUL")
183+ }
184+ options.launchConfig, launchSet = value, true
185+ default:
186+ return uiDriverOptions{}, usagef("unknown `--ui-driver` option %q", name)
187+ }
188+ }
189+ if socketSet && (sessionSet || colsSet || rowsSet || remoteSet || launchSet) {
190+ return uiDriverOptions{}, usagef("`--socket` cannot be combined with headless options")
191+ }
192+ return options, nil
193+}
194+
195+type launchEndpoint struct {
196+ binary string
197+ root string
198+ env map[string]string
199+}
200+
201+type launchConfig struct {
202+ local *launchEndpoint
203+ remotes map[string]launchEndpoint
204+}
205+
206+func parseLaunchConfig(path string) (launchConfig, error) {
207+ var result launchConfig
208+ info, err := os.Lstat(path)
209+ if err != nil {
210+ return result, fmt.Errorf("vev: read launch config: %w", err)
211+ }
212+ if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
213+ return result, errors.New("vev: launch config must be a regular file")
214+ }
215+ if info.Mode().Perm() != 0o600 {
216+ return result, errors.New("vev: launch config must have mode 0600")
217+ }
218+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() {
219+ return result, errors.New("vev: launch config must be owned by the current user")
220+ }
221+ file, err := os.Open(path)
222+ if err != nil {
223+ return result, fmt.Errorf("vev: open launch config: %w", err)
224+ }
225+ defer func() { _ = file.Close() }()
226+ data, err := io.ReadAll(io.LimitReader(file, launchConfigMaxBytes+1))
227+ if err != nil {
228+ return result, fmt.Errorf("vev: read launch config: %w", err)
229+ }
230+ if len(data) > launchConfigMaxBytes || !utf8.Valid(data) || !uniqueLaunchJSON(data) {
231+ return result, errors.New("vev: invalid launch config")
232+ }
233+ fields, ok := launchObject(data)
234+ if !ok {
235+ return result, errors.New("vev: launch config must be a JSON object")
236+ }
237+ version, ok := launchUint(fields["version"])
238+ if !ok || version != launchConfigVersion {
239+ return result, errors.New("vev: unsupported launch config version")
240+ }
241+ if !launchAllowedFields(fields, "version", "local", "remotes") {
242+ return result, errors.New("vev: launch config contains unknown fields")
243+ }
244+ if raw, exists := fields["local"]; exists {
245+ if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
246+ return result, errors.New("vev: launch config local endpoint cannot be null")
247+ }
248+ endpoint, err := parseLaunchEndpoint(raw, false)
249+ if err != nil {
250+ return result, err
251+ }
252+ result.local = &endpoint
253+ }
254+ result.remotes = make(map[string]launchEndpoint)
255+ if raw, exists := fields["remotes"]; exists {
256+ var entries []json.RawMessage
257+ if json.Unmarshal(raw, &entries) != nil || entries == nil {
258+ return result, errors.New("vev: launch config remotes must be an array")
259+ }
260+ for _, entry := range entries {
261+ object, ok := launchObject(entry)
262+ if !ok || !launchExactFields(object, "endpoint", "binary", "root", "env") {
263+ return result, errors.New("vev: invalid remote launch endpoint")
264+ }
265+ endpointName, ok := launchString(object["endpoint"])
266+ if !ok {
267+ return result, errors.New("vev: remote launch endpoint is required")
268+ }
269+ if err := domain.ValidateRemoteHostTarget(endpointName); err != nil {
270+ return result, fmt.Errorf("vev: invalid remote launch endpoint: %w", err)
271+ }
272+ endpoint, err := parseLaunchEndpoint(entry, true)
273+ if err != nil {
274+ return result, err
275+ }
276+ if _, duplicate := result.remotes[endpointName]; duplicate {
277+ return result, errors.New("vev: duplicate remote launch endpoint")
278+ }
279+ result.remotes[endpointName] = endpoint
280+ }
281+ }
282+ return result, nil
283+}
284+
285+func parseLaunchEndpoint(data []byte, remote bool) (launchEndpoint, error) {
286+ fields, ok := launchObject(data)
287+ allowed := []string{"binary", "root", "env"}
288+ if remote {
289+ allowed = append(allowed, "endpoint")
290+ }
291+ if !ok || !launchExactFields(fields, allowed...) {
292+ return launchEndpoint{}, errors.New("vev: invalid launch endpoint")
293+ }
294+ binary, ok := launchString(fields["binary"])
295+ if !ok || !filepath.IsAbs(binary) || strings.IndexByte(binary, 0) >= 0 {
296+ return launchEndpoint{}, errors.New("vev: launch binary must be an absolute path")
297+ }
298+ root, ok := launchString(fields["root"])
299+ if !ok || !filepath.IsAbs(root) || strings.IndexByte(root, 0) >= 0 {
300+ return launchEndpoint{}, errors.New("vev: launch root must be an absolute path")
301+ }
302+ env, ok := launchEnvironment(fields["env"])
303+ if !ok {
304+ return launchEndpoint{}, errors.New("vev: invalid launch environment")
305+ }
306+ return launchEndpoint{binary: binary, root: root, env: env}, nil
307+}
308+
309+func launchEnvironment(data []byte) (map[string]string, bool) {
310+ object, ok := launchObject(data)
311+ if !ok {
312+ return nil, false
313+ }
314+ result := make(map[string]string, len(object))
315+ for name, raw := range object {
316+ if !validEnvironmentName(name) || isReservedLaunchEnvironment(name) {
317+ return nil, false
318+ }
319+ value, ok := launchString(raw)
320+ if !ok || strings.IndexByte(value, 0) >= 0 {
321+ return nil, false
322+ }
323+ result[name] = value
324+ }
325+ return result, true
326+}
327+
328+func validEnvironmentName(name string) bool {
329+ if name == "" || !(name[0] == '_' || name[0] >= 'A' && name[0] <= 'Z' || name[0] >= 'a' && name[0] <= 'z') {
330+ return false
331+ }
332+ for i := 1; i < len(name); i++ {
333+ value := name[i]
334+ if !(value == '_' || value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9') {
335+ return false
336+ }
337+ }
338+ return true
339+}
340+
341+func isReservedLaunchEnvironment(name string) bool {
342+ switch name {
343+ case "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "VEV_ENV", "VEV_ENV_ROOT", launchAllowedRemoteEndpointsEnv:
344+ return true
345+ default:
346+ return false
347+ }
348+}
349+
350+func launchObject(data []byte) (map[string]json.RawMessage, bool) {
351+ var fields map[string]json.RawMessage
352+ if json.Unmarshal(data, &fields) != nil || fields == nil {
353+ return nil, false
354+ }
355+ return fields, true
356+}
357+
358+func launchString(data []byte) (string, bool) {
359+ var value string
360+ if bytes.Equal(bytes.TrimSpace(data), []byte("null")) || json.Unmarshal(data, &value) != nil || !utf8.ValidString(value) {
361+ return "", false
362+ }
363+ return value, true
364+}
365+
366+func launchUint(data []byte) (uint64, bool) {
367+ var value uint64
368+ if bytes.Equal(bytes.TrimSpace(data), []byte("null")) || json.Unmarshal(data, &value) != nil {
369+ return 0, false
370+ }
371+ return value, true
372+}
373+
374+func launchAllowedFields(fields map[string]json.RawMessage, names ...string) bool {
375+ allowed := make(map[string]bool, len(names))
376+ for _, name := range names {
377+ allowed[name] = true
378+ }
379+ for name := range fields {
380+ if !allowed[name] {
381+ return false
382+ }
383+ }
384+ return true
385+}
386+
387+func launchExactFields(fields map[string]json.RawMessage, names ...string) bool {
388+ if !launchAllowedFields(fields, names...) {
389+ return false
390+ }
391+ for _, name := range names {
392+ if _, ok := fields[name]; !ok {
393+ return false
394+ }
395+ }
396+ return true
397+}
398+
399+func uniqueLaunchJSON(data []byte) bool {
400+ decoder := json.NewDecoder(bytes.NewReader(data))
401+ var value func(int) bool
402+ value = func(depth int) bool {
403+ if depth > launchConfigMaxDepth {
404+ return false
405+ }
406+ token, err := decoder.Token()
407+ if err != nil {
408+ return false
409+ }
410+ delimiter, ok := token.(json.Delim)
411+ if !ok {
412+ return true
413+ }
414+ switch delimiter {
415+ case '{':
416+ seen := make(map[string]bool)
417+ for decoder.More() {
418+ token, err := decoder.Token()
419+ name, ok := token.(string)
420+ if err != nil || !ok || seen[name] {
421+ return false
422+ }
423+ seen[name] = true
424+ if !value(depth + 1) {
425+ return false
426+ }
427+ }
428+ case '[':
429+ for decoder.More() {
430+ if !value(depth + 1) {
431+ return false
432+ }
433+ }
434+ default:
435+ return false
436+ }
437+ _, err = decoder.Token()
438+ return err == nil
439+ }
440+ if !value(0) {
441+ return false
442+ }
443+ _, err := decoder.Token()
444+ return errors.Is(err, io.EOF)
445+}
446+
447+func createLaunchRoot(root string) error {
448+ if !filepath.IsAbs(root) {
449+ return errors.New("vev: launch root must be absolute")
450+ }
451+ if _, err := os.Lstat(root); err == nil {
452+ return errors.New("vev: launch root already exists")
453+ } else if !errors.Is(err, os.ErrNotExist) {
454+ return fmt.Errorf("vev: inspect launch root: %w", err)
455+ }
456+ if err := os.Mkdir(root, 0o700); err != nil {
457+ return fmt.Errorf("vev: create launch root: %w", err)
458+ }
459+ if err := safedir.EnsurePrivate(root); err != nil {
460+ _ = os.Remove(root)
461+ return fmt.Errorf("vev: secure launch root: %w", err)
462+ }
463+ for _, child := range []string{"config", "state", "runtime", "tmp"} {
464+ if err := safedir.EnsurePrivate(filepath.Join(root, child)); err != nil {
465+ _ = os.RemoveAll(root)
466+ return fmt.Errorf("vev: create launch directory: %w", err)
467+ }
468+ }
469+ return nil
470+}
471+
472+type environmentValue struct {
473+ value string
474+ set bool
475+}
476+
477+type environmentRestore struct {
478+ values map[string]environmentValue
479+}
480+
481+func activateLaunchRoot(root string) (func(), error) {
482+ values := map[string]string{
483+ "XDG_CONFIG_HOME": filepath.Join(root, "config"),
484+ "XDG_STATE_HOME": filepath.Join(root, "state"),
485+ "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"),
486+ "VEV_ENV_ROOT": root,
487+ }
488+ keys := []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "VEV_ENV", "VEV_ENV_ROOT"}
489+ restore := environmentRestore{values: make(map[string]environmentValue, len(keys))}
490+ for _, key := range keys {
491+ value, set := os.LookupEnv(key)
492+ restore.values[key] = environmentValue{value: value, set: set}
493+ }
494+ rollback := func() {
495+ for key, previous := range restore.values {
496+ if previous.set {
497+ _ = os.Setenv(key, previous.value)
498+ } else {
499+ _ = os.Unsetenv(key)
500+ }
501+ }
502+ }
503+ for _, key := range keys {
504+ var err error
505+ if key == "VEV_ENV" {
506+ err = os.Unsetenv(key)
507+ } else {
508+ err = os.Setenv(key, values[key])
509+ }
510+ if err != nil {
511+ rollback()
512+ return nil, fmt.Errorf("vev: activate launch root: %w", err)
513+ }
514+ }
515+ return rollback, nil
516+}
517+
518+func launchEnvironmentSlice(endpoint launchEndpoint) []string {
519+ return launchEnvironmentSliceForConfig(endpoint, nil)
520+}
521+
522+func launchEnvironmentSliceForConfig(endpoint launchEndpoint, config *launchConfig) []string {
523+ values := make(map[string]string, len(endpoint.env)+6)
524+ for name, value := range endpoint.env {
525+ values[name] = value
526+ }
527+ values["XDG_CONFIG_HOME"] = filepath.Join(endpoint.root, "config")
528+ values["XDG_STATE_HOME"] = filepath.Join(endpoint.root, "state")
529+ values["XDG_RUNTIME_DIR"] = filepath.Join(endpoint.root, "runtime")
530+ values["VEV_ENV_ROOT"] = endpoint.root
531+ if config != nil {
532+ values[launchAllowedRemoteEndpointsEnv] = strings.Join(launchConfigRemoteTargets(config), "\n")
533+ }
534+ keys := make([]string, 0, len(values))
535+ for key := range values {
536+ keys = append(keys, key)
537+ }
538+ sort.Strings(keys)
539+ result := make([]string, 0, len(keys))
540+ for _, key := range keys {
541+ result = append(result, key+"="+values[key])
542+ }
543+ return result
544+}
545+
546+func launchConfigRemoteTargets(config *launchConfig) []string {
547+ if config == nil || len(config.remotes) == 0 {
548+ return nil
549+ }
550+ targets := make([]string, 0, len(config.remotes))
551+ for target := range config.remotes {
552+ targets = append(targets, target)
553+ }
554+ sort.Strings(targets)
555+ return targets
556+}
557+
558+func clientTerminal(deps runAttachDeps) ports.Terminal {
559+ if deps.terminal != nil {
560+ if terminal := deps.terminal(); terminal != nil {
561+ return terminal
562+ }
563+ }
564+ return term.New()
565+}
566+
567+func clientClock(deps runAttachDeps) ports.Clock {
568+ if deps.clock != nil {
569+ if value := deps.clock(); value != nil {
570+ return value
571+ }
572+ }
573+ return clock.New()
574+}
575+
576+func clientEnvironment(factory func(string) []string, target string) []string {
577+ if factory == nil {
578+ return nil
579+ }
580+ return append([]string(nil), factory(target)...)
581+}
582+
583+func runUIDriver(ctx context.Context, options uiDriverOptions) error {
584+ if options.socket != "" {
585+ return uidriver.Bridge(ctx, options.socket, os.Stdin, os.Stdout)
586+ }
587+ return runHeadlessUIDriver(ctx, options)
588+}
589+
590+func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) (retErr error) {
591+ var config *launchConfig
592+ if options.launchConfig != "" {
593+ parsed, err := parseLaunchConfig(options.launchConfig)
594+ if err != nil {
595+ return err
596+ }
597+ config = &parsed
598+ if options.remote == "" && parsed.local == nil {
599+ return errors.New("vev: launch config requires a local endpoint for local headless attach")
600+ }
601+ if options.remote != "" {
602+ if _, ok := parsed.remotes[options.remote]; !ok {
603+ return &ports.UIError{Code: ports.UIErrEndpointNotConfigured}
604+ }
605+ }
606+ }
607+ remoteLaunches, err := newConfiguredRemoteLaunches(config)
608+ if err != nil {
609+ return err
610+ }
611+ var rootRestore func()
612+ var ownedRoot string
613+ removeRootOnReturn := true
614+ if config != nil && options.remote == "" {
615+ if err := createLaunchRoot(config.local.root); err != nil {
616+ return err
617+ }
618+ ownedRoot = config.local.root
619+ rootRestore, retErr = activateLaunchRoot(ownedRoot)
620+ if retErr != nil {
621+ _ = os.RemoveAll(ownedRoot)
622+ return retErr
623+ }
624+ defer func() {
625+ rootRestore()
626+ if !removeRootOnReturn {
627+ return
628+ }
629+ if err := removeOwnedLaunchRoot(ownedRoot); err != nil && retErr == nil {
630+ retErr = err
631+ }
632+ }()
633+ }
634+
635+ log, logCloser, err := configureLogging(logging.Client, false)
636+ if err != nil {
637+ return err
638+ }
639+ defer func() { _ = logCloser.Close() }()
640+ clk := clock.New()
641+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "")
642+ if err != nil {
643+ return fmt.Errorf("vev: create headless terminal: %w", err)
644+ }
645+ defer terminal.Close()
646+ ui := client.NewUI(terminal, clk)
647+
648+ localDir := ipc.SocketDir()
649+ deps := runAttachDeps{
650+ localDialer: func() wire.Dialer {
651+ if config != nil && config.local != nil {
652+ return localDaemonDialer{dir: localDir, executable: config.local.binary, environment: launchEnvironmentSliceForConfig(*config.local, config)}
653+ }
654+ return localDaemonDialer{dir: localDir}
655+ },
656+ remoteDialerFactory: remoteLaunches.dialerFactory(),
657+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
658+ runClient: runClientWithDeps,
659+ createDetached: createDetachedLocalSession,
660+ clipboard: nil,
661+ ui: ui,
662+ terminal: func() ports.Terminal { return terminal },
663+ clock: func() ports.Clock { return clk },
664+ disableCapabilityProbe: true,
665+ localEnvironment: launchEnvironmentForConfig(config),
666+ remoteEnvironment: remoteEnvironmentForConfig(config),
667+ stateDir: platform.StateDir,
668+ }
669+ if options.remote == "" {
670+ // A headless named session creates a new local session; an omitted name
671+ // retains the ordinary ephemeral attach intent.
672+ intent := protocol.IntentEphemeral
673+ if options.session != "" {
674+ intent = protocol.IntentNew
675+ }
676+ runErr := runHeadlessClient(ctx, intent, options.session, "", log, deps, ui, terminal)
677+ cleanupErr := cleanupHeadlessEndpoint(ownedRoot, remoteLaunches)
678+ if cleanupErr != nil && ownedRoot != "" {
679+ removeRootOnReturn = false
680+ }
681+ return errors.Join(runErr, cleanupErr)
682+ }
683+ intent := protocol.IntentEphemeral
684+ if options.session != "" {
685+ intent = protocol.IntentAttach
686+ }
687+ runErr := runHeadlessClient(ctx, intent, options.session, options.remote, log, deps, ui, terminal)
688+ cleanupErr := cleanupHeadlessEndpoint(ownedRoot, remoteLaunches)
689+ if cleanupErr != nil && ownedRoot != "" {
690+ removeRootOnReturn = false
691+ }
692+ return errors.Join(runErr, cleanupErr)
693+}
694+
695+func launchEnvironmentForConfig(config *launchConfig) []string {
696+ if config == nil || config.local == nil {
697+ return nil
698+ }
699+ return launchEnvironmentSliceForConfig(*config.local, config)
700+}
701+
702+func remoteEnvironmentForConfig(config *launchConfig) func(string) []string {
703+ if config == nil {
704+ return nil
705+ }
706+ return func(target string) []string {
707+ endpoint, ok := config.remotes[target]
708+ if !ok {
709+ return nil
710+ }
711+ return launchEnvironmentSliceForConfig(endpoint, config)
712+ }
713+}
714+
715+type configuredRemoteLaunches struct {
716+ config *launchConfig
717+ factory remoteadapter.DialerFactory
718+ mu sync.Mutex
719+ tokens map[string]string
720+ attempted map[string]struct{}
721+}
722+
723+func newConfiguredRemoteLaunches(config *launchConfig) (*configuredRemoteLaunches, error) {
724+ if config == nil {
725+ return nil, nil
726+ }
727+ launches := &configuredRemoteLaunches{config: config, factory: remoteadapter.NewDialerFactory(), tokens: make(map[string]string, len(config.remotes)), attempted: make(map[string]struct{})}
728+ for target := range config.remotes {
729+ token, err := newLaunchOwnerToken()
730+ if err != nil {
731+ return nil, fmt.Errorf("vev: create remote launch owner: %w", err)
732+ }
733+ launches.tokens[target] = token
734+ }
735+ return launches, nil
736+}
737+
738+func newLaunchOwnerToken() (string, error) {
739+ value := make([]byte, 18)
740+ if _, err := rand.Read(value); err != nil {
741+ return "", err
742+ }
743+ return base64.RawURLEncoding.EncodeToString(value), nil
744+}
745+
746+func (l *configuredRemoteLaunches) dialerFactory() remoteDialerForTarget {
747+ if l == nil {
748+ return defaultRemoteDialerFactory()
749+ }
750+ return func(target, session string, mode remoteadapter.TransportMode, log *slog.Logger) (wire.Dialer, error) {
751+ endpoint, ok := l.config.remotes[target]
752+ if !ok {
753+ return nil, &ports.UIError{Code: ports.UIErrEndpointNotConfigured}
754+ }
755+ l.mu.Lock()
756+ l.attempted[target] = struct{}{}
757+ token := l.tokens[target]
758+ l.mu.Unlock()
759+ launch := &remoteadapter.EndpointLaunch{Binary: endpoint.binary, Root: endpoint.root, OwnerToken: token, Environment: launchEnvironmentSliceForConfig(endpoint, l.config)}
760+ return l.factory.DialerForRemoteWithLaunch(target, session, mode, log, launch)
761+ }
762+}
763+
764+func (l *configuredRemoteLaunches) cleanup(ctx context.Context) error {
765+ if l == nil {
766+ return nil
767+ }
768+ l.mu.Lock()
769+ targets := make([]string, 0, len(l.attempted))
770+ tokens := make(map[string]string, len(l.attempted))
771+ for target := range l.attempted {
772+ targets = append(targets, target)
773+ tokens[target] = l.tokens[target]
774+ }
775+ l.mu.Unlock()
776+ sort.Strings(targets)
777+ var cleanupErr error
778+ for _, target := range targets {
779+ endpoint := l.config.remotes[target]
780+ spec := sshstdio.BuildCommandForRemoteCleanup(target, endpoint.root, tokens[target], endpoint.binary, launchEnvironmentSliceForConfig(endpoint, l.config), uiRemoteCleanupCommand)
781+ if err := sshstdio.RunRemoteCommand(ctx, spec); err != nil {
782+ cleanupErr = errors.Join(cleanupErr, fmt.Errorf("vev: clean remote launch endpoint: %w", err))
783+ }
784+ }
785+ return cleanupErr
786+}
787+
788+func configuredRemoteDialerFactory(config *launchConfig) remoteDialerForTarget {
789+ launches, err := newConfiguredRemoteLaunches(config)
790+ if err != nil {
791+ return func(string, string, remoteadapter.TransportMode, *slog.Logger) (wire.Dialer, error) { return nil, err }
792+ }
793+ return launches.dialerFactory()
794+}
795+
796+// cleanupHeadlessEndpoint is the explicit disposable-endpoint teardown. It
797+// runs only after the driver stream has detached, never from the JSONL server
798+// or the shared client runner.
799+func cleanupHeadlessEndpoint(ownedRoot string, remote *configuredRemoteLaunches) error {
800+ if ownedRoot == "" && remote == nil {
801+ return nil
802+ }
803+ cleanupCtx, cancel := context.WithTimeout(context.Background(), uiDriverCleanupTimeout)
804+ defer cancel()
805+ var cleanupErr error
806+ if ownedRoot != "" {
807+ stopErr := requestDaemonStop(cleanupCtx)
808+ if stopErr != nil && !errors.Is(stopErr, errDaemonNotRunning) {
809+ cleanupErr = errors.Join(cleanupErr, stopErr)
810+ }
811+ }
812+ if remote != nil {
813+ cleanupErr = errors.Join(cleanupErr, remote.cleanup(cleanupCtx))
814+ }
815+ return cleanupErr
816+}
817+
818+func runHeadlessClient(ctx context.Context, intent uint8, name, remoteTarget string, log *slog.Logger, deps runAttachDeps, ui *client.UI, terminal *uiterm.Terminal) error {
819+ return runHeadlessClientWithStream(ctx, intent, name, remoteTarget, log, deps, ui, terminal, &stdioStream{reader: os.Stdin, writer: os.Stdout})
820+}
821+
822+func runHeadlessClientWithStream(ctx context.Context, intent uint8, name, remoteTarget string, log *slog.Logger, deps runAttachDeps, ui *client.UI, terminal *uiterm.Terminal, stream io.ReadWriteCloser) (retErr error) {
823+ runCtx, cancel := context.WithCancel(ctx)
824+ defer cancel()
825+ runnerDone := make(chan error, 1)
826+ go func() { runnerDone <- runAttachWithDeps(runCtx, intent, name, remoteTarget, "", log, deps) }()
827+
828+ snapshot, err := waitForHeadlessPublication(runCtx, ui)
829+ if err != nil {
830+ cancel()
831+ runErr := <-runnerDone
832+ return errors.Join(err, ignoreContextCancellation(runErr))
833+ }
834+ ready := uidriver.Ready{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Control: true, Status: snapshot.Context.Status}
835+ if ready.Generation == 0 {
836+ ready.Generation = 1
837+ }
838+ if ready.Status == "" {
839+ ready.Status = ports.UIStatusAttached
840+ }
841+ server := uidriver.New(ui, deps.clock())
842+ serveDone := make(chan error, 1)
843+ go func() { serveDone <- server.Serve(runCtx, stream, ready) }()
844+
845+ select {
846+ case serveErr := <-serveDone:
847+ cancel()
848+ runErr := <-runnerDone
849+ if serveErr != nil {
850+ retErr = errors.Join(serveErr, ignoreContextCancellation(runErr))
851+ } else {
852+ retErr = ignoreContextCancellation(runErr)
853+ }
854+ case runErr := <-runnerDone:
855+ cancel()
856+ serveErr := <-serveDone
857+ retErr = errors.Join(runErr, ignoreContextCancellation(serveErr))
858+ }
859+ return retErr
860+}
861+
862+func waitForHeadlessPublication(ctx context.Context, ui *client.UI) (ports.UISnapshot, error) {
863+ readyCtx, cancel := context.WithTimeout(ctx, protocol.HandshakeTimeout)
864+ defer cancel()
865+ snapshot, err := ui.WaitForSnapshot(readyCtx, func(snapshot ports.UISnapshot) bool {
866+ return snapshot.Context.Generation != 0 && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication != 0
867+ })
868+ if err != nil {
869+ if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
870+ return ports.UISnapshot{}, fmt.Errorf("vev: headless attachment did not publish an initial view: %w", err)
871+ }
872+ return ports.UISnapshot{}, err
873+ }
874+ return snapshot, nil
875+}
876+
877+func removeOwnedLaunchRoot(root string) error {
878+ if root == "" {
879+ return nil
880+ }
881+ info, err := os.Lstat(root)
882+ if err != nil {
883+ if errors.Is(err, os.ErrNotExist) {
884+ return nil
885+ }
886+ return fmt.Errorf("vev: inspect launch root during cleanup: %w", err)
887+ }
888+ if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
889+ return errors.New("vev: refusing to remove an unsafe launch root")
890+ }
891+ if stat, ok := info.Sys().(*syscall.Stat_t); !ok || int(stat.Uid) != os.Getuid() {
892+ return errors.New("vev: refusing to remove a launch root owned by another user")
893+ }
894+ if err := os.RemoveAll(root); err != nil {
895+ return fmt.Errorf("vev: remove launch root: %w", err)
896+ }
897+ return nil
898+}
899+
900+func ignoreContextCancellation(err error) error {
901+ if errors.Is(err, context.Canceled) {
902+ return nil
903+ }
904+ return err
905+}
906+
907+type observedTerminal struct {
908+ ports.Terminal
909+ ports.UIOutputTransaction
910+}
911+
912+type stdioStream struct {
913+ reader io.Reader
914+ writer io.Writer
915+ once sync.Once
916+}
917+
918+func (s *stdioStream) Read(data []byte) (int, error) { return s.reader.Read(data) }
919+func (s *stdioStream) Write(data []byte) (int, error) { return s.writer.Write(data) }
920+func (s *stdioStream) Close() error {
921+ var closeErr error
922+ s.once.Do(func() {
923+ for _, value := range []io.Writer{s.writer} {
924+ if closer, ok := value.(io.Closer); ok {
925+ closeErr = closer.Close()
926+ }
927+ }
928+ if closer, ok := s.reader.(io.Closer); ok {
929+ if err := closer.Close(); closeErr == nil {
930+ closeErr = err
931+ }
932+ }
933+ })
934+ return closeErr
935+}
936+
937+// runAttachWithOptions is the composition path for ordinary interactive
938+// clients. Observation is opt-in; the default path remains term.New().
939+func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) {
940+ if !options.observe && !options.control && options.socket == "" {
941+ return runAttach(ctx, intent, name, remoteTarget)
942+ }
943+ if options.control {
944+ options.observe = true
945+ }
946+ ctx, stop := signal.NotifyContext(ctx, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
947+ defer stop()
948+ log, logCloser, err := configureLogging(logging.Client, false)
949+ if err != nil {
950+ return err
951+ }
952+ defer func() { _ = logCloser.Close() }()
953+ clk := clock.New()
954+ observer, observerCloser, err := newPerformanceTrace(clk)
955+ if err != nil {
956+ return fmt.Errorf("vev: performance trace: %w", err)
957+ }
958+ if observerCloser != nil {
959+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
960+ }
961+ physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil)
962+ geometry, err := physical.Geometry()
963+ if err != nil {
964+ return fmt.Errorf("vev: reading terminal geometry: %w", err)
965+ }
966+ mirror, err := uiterm.NewMirror(ctx, geometry, "")
967+ if err != nil {
968+ return fmt.Errorf("vev: create UI mirror: %w", err)
969+ }
970+ defer mirror.Close()
971+ physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror)
972+ ui := client.NewUI(mirror, clk)
973+ var endpoint *uidriver.UnixEndpoint
974+ if options.observe {
975+ server := uidriver.New(ui, clk)
976+ socketPath := options.socket
977+ if socketPath == "" {
978+ socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle())
979+ }
980+ endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready {
981+ ready := uidriver.Ready{Attachment: ui.Handle(), Generation: 1, Control: options.control, Status: ports.UIStatusReconnecting}
982+ if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil {
983+ ready.Generation = snapshot.Context.Generation
984+ ready.Status = snapshot.Context.Status
985+ }
986+ return ready
987+ })
988+ if err != nil {
989+ return err
990+ }
991+ fmt.Fprintln(os.Stderr, socketPath)
992+ defer func() { _ = endpoint.Close() }()
993+ }
994+ return runAttachWithDeps(ctx, intent, name, remoteTarget, "", log, runAttachDeps{
995+ localDialer: func() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir(), observer: observer} },
996+ remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
997+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
998+ runClient: runClientWithDeps,
999+ createDetached: createDetachedLocalSession,
1000+ clipboard: clipboard.New(),
1001+ runtimeObserver: observer,
1002+ ui: ui,
1003+ terminal: func() ports.Terminal { return observedTerminal{Terminal: physical, UIOutputTransaction: mirror} },
1004+ clock: func() ports.Clock { return clk },
1005+ })
1006+}
A internal/app/ui_driver_acceptance_test.go +160 -0
@@ -0,0 +1,160 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "net"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/adapters/clock"
14+ "github.com/bnema/vev/internal/adapters/uiterm"
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+ "github.com/stretchr/testify/require"
21+)
22+
23+type driverTestEnvelope struct {
24+ Version int `json:"version"`
25+ ID uint64 `json:"id"`
26+ Result json.RawMessage `json:"result"`
27+ Error *struct {
28+ Code ports.UIErrorCode `json:"code"`
29+ Accepted bool `json:"accepted"`
30+ ActionID uint64 `json:"action_id,omitempty"`
31+ } `json:"error,omitempty"`
32+}
33+
34+func TestHeadlessDriverUsesRealRunnerAndDaemon(t *testing.T) {
35+ dir, _ := startDaemon(t)
36+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
37+ defer cancel()
38+ clk := clock.New()
39+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
40+ require.NoError(t, err)
41+ defer terminal.Close()
42+ ui := client.NewUI(terminal, clk)
43+ deps := runAttachDeps{
44+ localDialer: func() wire.Dialer { return localDaemonDialer{dir: dir} },
45+ remoteDialerFactory: defaultRemoteDialerFactory(),
46+ runClient: runClientWithDeps,
47+ createDetached: createDetachedLocalSession,
48+ ui: ui,
49+ terminal: func() ports.Terminal { return terminal },
50+ clock: func() ports.Clock { return clk },
51+ stateDir: func() string { return dir },
52+ }
53+ clientSide, serverSide := net.Pipe()
54+ done := make(chan error, 1)
55+ go func() {
56+ done <- runHeadlessClientWithStream(ctx, protocol.IntentEphemeral, "", "", discardLog(), deps, ui, terminal, serverSide)
57+ }()
58+ defer clientSide.Close()
59+ decoder := json.NewDecoder(clientSide)
60+ ready := readDriverTestEnvelope(t, decoder)
61+ require.Equal(t, uint64(0), ready.ID)
62+ var readyResult struct {
63+ Attachment string `json:"attachment"`
64+ Generation uint64 `json:"generation"`
65+ }
66+ require.NoError(t, json.Unmarshal(ready.Result, &readyResult))
67+ require.NotEmpty(t, readyResult.Attachment)
68+ require.Equal(t, uint64(1), readyResult.Generation)
69+
70+ writeDriverTestRequest(t, clientSide, map[string]any{
71+ "version": 1, "id": 1, "op": "keys", "attachment": readyResult.Attachment,
72+ "generation": readyResult.Generation, "keys": []string{"Alt+Space"},
73+ })
74+ paletteAction := readDriverTestEnvelope(t, decoder)
75+ require.Nil(t, paletteAction.Error)
76+ var paletteResult struct {
77+ ActionID uint64 `json:"action_id"`
78+ }
79+ require.NoError(t, json.Unmarshal(paletteAction.Result, &paletteResult))
80+ require.NotZero(t, paletteResult.ActionID)
81+ writeDriverTestRequest(t, clientSide, map[string]any{
82+ "version": 1, "id": 2, "op": "wait", "attachment": readyResult.Attachment,
83+ "after_action": paletteResult.ActionID, "expect": map[string]string{"text_contains": "Commands"},
84+ })
85+ paletteWait := readDriverTestEnvelope(t, decoder)
86+ require.Nil(t, paletteWait.Error)
87+ writeDriverTestRequest(t, clientSide, map[string]any{
88+ "version": 1, "id": 3, "op": "keys", "attachment": readyResult.Attachment,
89+ "generation": readyResult.Generation, "keys": []string{"Escape"},
90+ })
91+ paletteClose := readDriverTestEnvelope(t, decoder)
92+ require.Nil(t, paletteClose.Error)
93+
94+ writeDriverTestRequest(t, clientSide, map[string]any{
95+ "version": 1, "id": 4, "op": "text", "attachment": readyResult.Attachment,
96+ "generation": readyResult.Generation, "text": "printf 'HEADLESS_%s' OK",
97+ })
98+ textAction := readDriverTestEnvelope(t, decoder)
99+ require.Nil(t, textAction.Error)
100+ var actionResult struct {
101+ ActionID uint64 `json:"action_id"`
102+ Status ports.UIActionStatus `json:"status"`
103+ }
104+ require.NoError(t, json.Unmarshal(textAction.Result, &actionResult))
105+ require.Equal(t, ports.UIActionProcessed, actionResult.Status)
106+
107+ writeDriverTestRequest(t, clientSide, map[string]any{
108+ "version": 1, "id": 5, "op": "keys", "attachment": readyResult.Attachment,
109+ "generation": readyResult.Generation, "keys": []string{"Enter"},
110+ })
111+ enterAction := readDriverTestEnvelope(t, decoder)
112+ require.Nil(t, enterAction.Error)
113+ var enterResult struct {
114+ ActionID uint64 `json:"action_id"`
115+ }
116+ require.NoError(t, json.Unmarshal(enterAction.Result, &enterResult))
117+ require.NotZero(t, enterResult.ActionID)
118+
119+ writeDriverTestRequest(t, clientSide, map[string]any{
120+ "version": 1, "id": 6, "op": "wait", "attachment": readyResult.Attachment,
121+ "after_action": enterResult.ActionID, "expect": map[string]string{"text_contains": "HEADLESS_OK"},
122+ })
123+ wait := readDriverTestEnvelope(t, decoder)
124+ require.Nil(t, wait.Error)
125+
126+ writeDriverTestRequest(t, clientSide, map[string]any{
127+ "version": 1, "id": 7, "op": "capture", "attachment": readyResult.Attachment,
128+ })
129+ capture := readDriverTestEnvelope(t, decoder)
130+ require.Nil(t, capture.Error)
131+ var captureResult struct {
132+ Text string `json:"text"`
133+ }
134+ require.NoError(t, json.Unmarshal(capture.Result, &captureResult))
135+ require.Contains(t, captureResult.Text, "HEADLESS_OK")
136+
137+ require.NoError(t, clientSide.Close())
138+ select {
139+ case err := <-done:
140+ require.NoError(t, err)
141+ case <-time.After(3 * time.Second):
142+ t.Fatal("headless runner did not stop after driver EOF")
143+ }
144+}
145+
146+func readDriverTestEnvelope(t *testing.T, decoder *json.Decoder) driverTestEnvelope {
147+ t.Helper()
148+ var envelope driverTestEnvelope
149+ require.NoError(t, decoder.Decode(&envelope))
150+ require.Equal(t, 1, envelope.Version)
151+ return envelope
152+}
153+
154+func writeDriverTestRequest(t *testing.T, conn net.Conn, request map[string]any) {
155+ t.Helper()
156+ data, err := json.Marshal(request)
157+ require.NoError(t, err)
158+ _, err = fmt.Fprintf(conn, "%s\n", data)
159+ require.NoError(t, err)
160+}
A internal/app/ui_driver_interactive_test.go +66 -0
@@ -0,0 +1,66 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "context"
7+ "io"
8+ "os"
9+ "strings"
10+ "testing"
11+
12+ "github.com/bnema/vev/internal/adapters/term"
13+ "github.com/bnema/vev/internal/adapters/uiterm"
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestInteractiveObservationPublishesPhysicalWriterTransaction(t *testing.T) {
20+ inputReader, inputWriter, err := os.Pipe()
21+ require.NoError(t, err)
22+ defer func() { _ = inputReader.Close() }()
23+ defer func() { _ = inputWriter.Close() }()
24+ outputReader, outputWriter, err := os.Pipe()
25+ require.NoError(t, err)
26+ defer func() { _ = outputReader.Close() }()
27+ defer func() { _ = outputWriter.Close() }()
28+
29+ ctx, cancel := context.WithCancel(context.Background())
30+ defer cancel()
31+ mirror, err := uiterm.NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
32+ require.NoError(t, err)
33+ defer mirror.Close()
34+ physical := term.NewWithFilesAndObservation(inputReader, outputWriter, mirror)
35+ observed := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
36+ contextValue := ports.UIContext{AttachmentHandle: "attachment", Generation: 1, Status: ports.UIStatusAttached}
37+
38+ observed.BeginOutput(contextValue)
39+ _, err = observed.Out().Write([]byte("hello"))
40+ require.NoError(t, err)
41+ require.NoError(t, observed.Flush())
42+ observed.EndOutput(true)
43+ _, err = io.ReadAll(io.LimitReader(outputReader, int64(len("hello"))))
44+ require.NoError(t, err)
45+
46+ snapshot, err := mirror.Snapshot()
47+ require.NoError(t, err)
48+ require.Equal(t, contextValue.AttachmentHandle, snapshot.Context.AttachmentHandle)
49+ require.Equal(t, contextValue.Generation, snapshot.Context.Generation)
50+ require.True(t, strings.HasPrefix(snapshotText(snapshot), "hello"))
51+}
52+
53+func snapshotText(snapshot ports.UISnapshot) string {
54+ var text []byte
55+ for _, cell := range snapshot.Cells {
56+ if cell.Continuation {
57+ continue
58+ }
59+ if cell.Text == "" {
60+ text = append(text, ' ')
61+ continue
62+ }
63+ text = append(text, cell.Text...)
64+ }
65+ return string(text)
66+}
A internal/app/ui_driver_test.go +196 -0
@@ -0,0 +1,196 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestParseUIDriverArgs(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ args []string
17+ want uiDriverOptions
18+ wantErr bool
19+ wantSocket bool
20+ }{
21+ {name: "defaults", want: uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}},
22+ {name: "headless", args: []string{"--session", "work", "--cols", "100", "--rows", "40", "--remote", "user@example.com", "--launch-config", "/tmp/config.json"}, want: uiDriverOptions{session: "work", cols: 100, rows: 40, remote: "user@example.com", launchConfig: "/tmp/config.json"}},
23+ {name: "socket", args: []string{"--socket", "/tmp/ui.sock"}, want: uiDriverOptions{socket: "/tmp/ui.sock", cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}, wantSocket: true},
24+ {name: "socket conflicts", args: []string{"--socket", "/tmp/ui.sock", "--rows", "20"}, wantErr: true},
25+ {name: "relative socket", args: []string{"--socket", "ui.sock"}, wantErr: true},
26+ {name: "duplicate socket", args: []string{"--socket", "/tmp/a.sock", "--socket", "/tmp/b.sock"}, wantErr: true},
27+ {name: "duplicate session", args: []string{"--session", "one", "--session", "two"}, wantErr: true},
28+ {name: "duplicate columns", args: []string{"--cols", "80", "--cols", "100"}, wantErr: true},
29+ {name: "duplicate rows", args: []string{"--rows", "24", "--rows", "30"}, wantErr: true},
30+ {name: "duplicate remote", args: []string{"--remote", "one.example", "--remote", "two.example"}, wantErr: true},
31+ {name: "duplicate launch config", args: []string{"--launch-config", "/tmp/one.json", "--launch-config", "/tmp/two.json"}, wantErr: true},
32+ {name: "missing socket", args: []string{"--socket"}, wantErr: true},
33+ {name: "missing session", args: []string{"--session"}, wantErr: true},
34+ {name: "missing columns", args: []string{"--cols"}, wantErr: true},
35+ {name: "missing rows", args: []string{"--rows"}, wantErr: true},
36+ {name: "missing remote", args: []string{"--remote"}, wantErr: true},
37+ {name: "missing launch config", args: []string{"--launch-config"}, wantErr: true},
38+ {name: "NUL launch config", args: []string{"--launch-config", "/tmp/\x00config.json"}, wantErr: true},
39+ {name: "unknown", args: []string{"--nope"}, wantErr: true},
40+ {name: "positional", args: []string{"work"}, wantErr: true},
41+ {name: "bad geometry", args: []string{"--cols", "0"}, wantErr: true},
42+ {name: "bad remote", args: []string{"--remote", "bad host"}, wantErr: true},
43+ }
44+ for _, tt := range tests {
45+ t.Run(tt.name, func(t *testing.T) {
46+ got, err := parseUIDriverArgs(tt.args)
47+ if tt.wantErr {
48+ require.Error(t, err)
49+ return
50+ }
51+ require.NoError(t, err)
52+ require.Equal(t, tt.want, got)
53+ if tt.wantSocket {
54+ require.NotEmpty(t, got.socket)
55+ }
56+ })
57+ }
58+}
59+
60+func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) {
61+ command, err := parseArgs([]string{"--ui-control", "new", "work"})
62+ require.NoError(t, err)
63+ require.Equal(t, kindAttach, command.kind)
64+ require.True(t, command.uiControl)
65+ require.False(t, command.uiObserve)
66+ driver, err := parseArgs([]string{"--ui-driver", "--session", "work"})
67+ require.NoError(t, err)
68+ require.Equal(t, kindUIDriver, driver.kind)
69+ require.Equal(t, "work", driver.uiDriver.session)
70+ _, err = parseArgs([]string{"ui-driver"})
71+ require.Error(t, err)
72+ _, err = parseArgs([]string{"--ui-driver", "--ui-observe"})
73+ require.Error(t, err)
74+ trailing, err := parseArgs([]string{"attach", "work", "--ui-observe", "--ui-socket", "/tmp/ui.sock"})
75+ require.NoError(t, err)
76+ require.True(t, trailing.uiObserve)
77+ require.Equal(t, "/tmp/ui.sock", trailing.uiSocket)
78+ _, err = parseArgs([]string{"--ui-observe", "ls"})
79+ require.Error(t, err)
80+ _, err = parseArgs([]string{"--ui-socket", "/tmp/ui.sock"})
81+ require.Error(t, err)
82+}
83+
84+func TestParseLaunchConfigStrictAndOptionalEndpoints(t *testing.T) {
85+ root := filepath.Join(t.TempDir(), "endpoint-root")
86+ configPath := filepath.Join(t.TempDir(), "launch.json")
87+ data := `{"version":1,"local":{"binary":"/bin/vev","root":"` + root + `","env":{"HOME":"/home/test","PATH":"/bin"}}}`
88+ require.NoError(t, os.WriteFile(configPath, []byte(data), 0o600))
89+ config, err := parseLaunchConfig(configPath)
90+ require.NoError(t, err)
91+ require.NotNil(t, config.local)
92+ require.Empty(t, config.remotes)
93+ require.Equal(t, "/bin/vev", config.local.binary)
94+
95+ for name, contents := range map[string]string{
96+ "duplicate": `{"version":1,"version":1}`,
97+ "escaped duplicate": `{"version":1,"local":{"binary":"/bin/vev","root":"/tmp/root","env":{"PA\u0054H":"/bin","PATH":"/usr/bin"}}}`,
98+ "unknown": `{"version":1,"bogus":true}`,
99+ "relative binary": `{"version":1,"local":{"binary":"vev","root":"/tmp/root","env":{}}}`,
100+ "reserved env": `{"version":1,"local":{"binary":"/bin/vev","root":"/tmp/root","env":{"XDG_RUNTIME_DIR":"/tmp"}}}`,
101+ } {
102+ t.Run(name, func(t *testing.T) {
103+ path := filepath.Join(t.TempDir(), "launch.json")
104+ require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
105+ _, err := parseLaunchConfig(path)
106+ require.Error(t, err)
107+ })
108+ }
109+}
110+
111+func TestParseLaunchConfigAcceptsRemoteEndpoint(t *testing.T) {
112+ root := filepath.Join(t.TempDir(), "remote-root")
113+ configPath := filepath.Join(t.TempDir(), "launch.json")
114+ data := `{"version":1,"remotes":[{"endpoint":"user@example.com","binary":"/bin/vev","root":"` + root + `","env":{"HOME":"/home/test","PATH":"/bin"}}]}`
115+ require.NoError(t, os.WriteFile(configPath, []byte(data), 0o600))
116+
117+ config, err := parseLaunchConfig(configPath)
118+ require.NoError(t, err)
119+ require.Nil(t, config.local)
120+ require.Equal(t, launchEndpoint{binary: "/bin/vev", root: root, env: map[string]string{"HOME": "/home/test", "PATH": "/bin"}}, config.remotes["user@example.com"])
121+}
122+
123+func TestLaunchEnvironmentForConfigIncludesRemoteAllowlist(t *testing.T) {
124+ config := &launchConfig{remotes: map[string]launchEndpoint{
125+ "user@z.example": {},
126+ "user@a.example": {},
127+ }}
128+ environment := launchEnvironmentSliceForConfig(launchEndpoint{root: "/tmp/root"}, config)
129+ var allowlist string
130+ for _, entry := range environment {
131+ if strings.HasPrefix(entry, launchAllowedRemoteEndpointsEnv+"=") {
132+ allowlist = strings.TrimPrefix(entry, launchAllowedRemoteEndpointsEnv+"=")
133+ }
134+ }
135+ require.Equal(t, "user@a.example\nuser@z.example", allowlist)
136+}
137+
138+func TestRemoteLaunchAllowlistFromEnvironment(t *testing.T) {
139+ t.Setenv(launchAllowedRemoteEndpointsEnv, "user@z.example\nuser@a.example")
140+ allowed, configured, err := remoteLaunchAllowlistFromEnv()
141+ require.NoError(t, err)
142+ require.True(t, configured)
143+ require.Equal(t, map[string]struct{}{"user@z.example": {}, "user@a.example": {}}, allowed)
144+
145+ t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host")
146+ _, configured, err = remoteLaunchAllowlistFromEnv()
147+ require.Error(t, err)
148+ require.True(t, configured)
149+
150+ t.Setenv(launchAllowedRemoteEndpointsEnv, "")
151+ allowed, configured, err = remoteLaunchAllowlistFromEnv()
152+ require.NoError(t, err)
153+ require.True(t, configured)
154+ require.Empty(t, allowed)
155+}
156+
157+func TestLaunchRootIsExclusiveAndPrivate(t *testing.T) {
158+ root := filepath.Join(t.TempDir(), "root")
159+ require.NoError(t, createLaunchRoot(root))
160+ info, err := os.Stat(root)
161+ require.NoError(t, err)
162+ require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
163+ for _, child := range []string{"config", "state", "runtime", "tmp"} {
164+ info, err := os.Stat(filepath.Join(root, child))
165+ require.NoError(t, err)
166+ require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
167+ }
168+ require.Error(t, createLaunchRoot(root))
169+ require.NoError(t, removeOwnedLaunchRoot(root))
170+ require.NoError(t, removeOwnedLaunchRoot(root))
171+}
172+
173+func TestConfiguredRemoteDialerRejectsUnlistedEndpoint(t *testing.T) {
174+ config := &launchConfig{remotes: map[string]launchEndpoint{"user@example.com": {binary: "/bin/vev", root: "/tmp/remote", env: map[string]string{}}}}
175+ factory := configuredRemoteDialerFactory(config)
176+ _, err := factory("other@example.com", "work", "udp", nil)
177+ var uiErr *ports.UIError
178+ require.ErrorAs(t, err, &uiErr)
179+ require.Equal(t, ports.UIErrEndpointNotConfigured, uiErr.Code)
180+ _, err = factory("user@example.com", "work", "unknown", nil)
181+ require.Error(t, err)
182+}
183+
184+func TestRunUIRemoteCleanupRequiresMatchingLaunchRuntime(t *testing.T) {
185+ root := filepath.Join(t.TempDir(), "launch-root")
186+ for name, runtimeDir := range map[string]string{
187+ "missing": "",
188+ "different": filepath.Join(root, "other"),
189+ } {
190+ t.Run(name, func(t *testing.T) {
191+ t.Setenv("VEV_ENV_ROOT", root)
192+ t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
193+ require.Error(t, runUIRemoteCleanup(t.Context()))
194+ })
195+ }
196+}
M internal/domain/lifecycle.go +12 -3
@@ -182,12 +182,21 @@ func ValidateRemoteDisplayOrigin(origin string) error {
182 // ValidateTabStableID validates the opaque stable tab identity used by182 // ValidateTabStableID validates the opaque stable tab identity used by
183 // picker and attachment handoffs.183 // picker and attachment handoffs.
184 func ValidateTabStableID(value TabStableID) error {184 func ValidateTabStableID(value TabStableID) error {
185- if value == "" || len(value) > 128 || !utf8.ValidString(string(value)) {185+ return validateStableID(string(value), "tab")
186- return errors.New("invalid stable tab ID")186+}
187+
188+// ValidatePaneStableID validates the session/tab-qualified focused pane identity.
189+func ValidatePaneStableID(value PaneStableID) error {
190+ return validateStableID(string(value), "pane")
191+}
192+
193+func validateStableID(value, kind string) error {
194+ if value == "" || len(value) > 128 || !utf8.ValidString(value) {
195+ return fmt.Errorf("invalid stable %s ID", kind)
187 }196 }
188 for _, r := range value {197 for _, r := range value {
189 if unicode.IsControl(r) || unicode.IsSpace(r) {198 if unicode.IsControl(r) || unicode.IsSpace(r) {
190- return errors.New("invalid stable tab ID")199+ return fmt.Errorf("invalid stable %s ID", kind)
191 }200 }
192 }201 }
193 return nil202 return nil
M internal/ports/mocks/recovery_mock.go +18 -18
@@ -657,8 +657,8 @@ func (_c *MockSnapshotRepository_DeleteIncarnation_Call) RunAndReturn(run func(c
657 }657 }
658 658
659 // LoadCheckpoint provides a mock function for the type MockSnapshotRepository659 // LoadCheckpoint provides a mock function for the type MockSnapshotRepository
660-func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error) {660+func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef) (ports.SnapshotGeneration, error) {
661- ret := _mock.Called(context1, incarnationID, s, v)661+ ret := _mock.Called(context1, incarnationID, s, checkpointRef)
662 662
663 if len(ret) == 0 {663 if len(ret) == 0 {
664 panic("no return value specified for LoadCheckpoint")664 panic("no return value specified for LoadCheckpoint")
@@ -667,15 +667,15 @@ func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, in
667 var r0 ports.SnapshotGeneration667 var r0 ports.SnapshotGeneration
668 var r1 error668 var r1 error
669 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error)); ok {669 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error)); ok {
670- return returnFunc(context1, incarnationID, s, v)670+ return returnFunc(context1, incarnationID, s, checkpointRef)
671 }671 }
672 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) ports.SnapshotGeneration); ok {672 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) ports.SnapshotGeneration); ok {
673- r0 = returnFunc(context1, incarnationID, s, v)673+ r0 = returnFunc(context1, incarnationID, s, checkpointRef)
674 } else {674 } else {
675 r0 = ret.Get(0).(ports.SnapshotGeneration)675 r0 = ret.Get(0).(ports.SnapshotGeneration)
676 }676 }
677 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) error); ok {677 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) error); ok {
678- r1 = returnFunc(context1, incarnationID, s, v)678+ r1 = returnFunc(context1, incarnationID, s, checkpointRef)
679 } else {679 } else {
680 r1 = ret.Error(1)680 r1 = ret.Error(1)
681 }681 }
@@ -691,12 +691,12 @@ type MockSnapshotRepository_LoadCheckpoint_Call struct {
691 // - context1 context.Context691 // - context1 context.Context
692 // - incarnationID domain.IncarnationID692 // - incarnationID domain.IncarnationID
693 // - s string693 // - s string
694-// - v ports.CheckpointRef694+// - checkpointRef ports.CheckpointRef
695-func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, v any) *MockSnapshotRepository_LoadCheckpoint_Call {695+func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, checkpointRef any) *MockSnapshotRepository_LoadCheckpoint_Call {
696- return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, v)}696+ return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, checkpointRef)}
697 }697 }
698 698
699-func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef)) *MockSnapshotRepository_LoadCheckpoint_Call {699+func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef)) *MockSnapshotRepository_LoadCheckpoint_Call {
700 _c.Call.Run(func(args mock.Arguments) {700 _c.Call.Run(func(args mock.Arguments) {
701 var arg0 context.Context701 var arg0 context.Context
702 if args[0] != nil {702 if args[0] != nil {
@@ -729,7 +729,7 @@ func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Return(snapshotGeneration
729 return _c729 return _c
730 }730 }
731 731
732-func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_LoadCheckpoint_Call {732+func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, checkpointRef ports.CheckpointRef) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_LoadCheckpoint_Call {
733 _c.Call.Return(run)733 _c.Call.Return(run)
734 return _c734 return _c
735 }735 }
@@ -792,8 +792,8 @@ func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 co
792 }792 }
793 793
794 // ReconcileCheckpoint provides a mock function for the type MockSnapshotRepository794 // ReconcileCheckpoint provides a mock function for the type MockSnapshotRepository
795-func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error {795+func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef) error {
796- ret := _mock.Called(context1, incarnationID, v)796+ ret := _mock.Called(context1, incarnationID, checkpointRef)
797 797
798 if len(ret) == 0 {798 if len(ret) == 0 {
799 panic("no return value specified for ReconcileCheckpoint")799 panic("no return value specified for ReconcileCheckpoint")
@@ -801,7 +801,7 @@ func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Contex
801 801
802 var r0 error802 var r0 error
803 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, ports.CheckpointRef) error); ok {803 if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, ports.CheckpointRef) error); ok {
804- r0 = returnFunc(context1, incarnationID, v)804+ r0 = returnFunc(context1, incarnationID, checkpointRef)
805 } else {805 } else {
806 r0 = ret.Error(0)806 r0 = ret.Error(0)
807 }807 }
@@ -816,12 +816,12 @@ type MockSnapshotRepository_ReconcileCheckpoint_Call struct {
816 // ReconcileCheckpoint is a helper method to define mock.On call816 // ReconcileCheckpoint is a helper method to define mock.On call
817 // - context1 context.Context817 // - context1 context.Context
818 // - incarnationID domain.IncarnationID818 // - incarnationID domain.IncarnationID
819-// - v ports.CheckpointRef819+// - checkpointRef ports.CheckpointRef
820-func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, v any) *MockSnapshotRepository_ReconcileCheckpoint_Call {820+func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, checkpointRef any) *MockSnapshotRepository_ReconcileCheckpoint_Call {
821- return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, v)}821+ return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, checkpointRef)}
822 }822 }
823 823
824-func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef)) *MockSnapshotRepository_ReconcileCheckpoint_Call {824+func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef)) *MockSnapshotRepository_ReconcileCheckpoint_Call {
825 _c.Call.Run(func(args mock.Arguments) {825 _c.Call.Run(func(args mock.Arguments) {
826 var arg0 context.Context826 var arg0 context.Context
827 if args[0] != nil {827 if args[0] != nil {
@@ -849,7 +849,7 @@ func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Return(err error) *Mo
849 return _c849 return _c
850 }850 }
851 851
852-func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error) *MockSnapshotRepository_ReconcileCheckpoint_Call {852+func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef) error) *MockSnapshotRepository_ReconcileCheckpoint_Call {
853 _c.Call.Return(run)853 _c.Call.Return(run)
854 return _c854 return _c
855 }855 }
A internal/ports/mocks/ui_mock.go +689 -0
@@ -0,0 +1,689 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockUIService creates a new instance of MockUIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockUIService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockUIService {
21+ mock := &MockUIService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockUIService is an autogenerated mock type for the UIService type
30+type MockUIService struct {
31+ mock.Mock
32+}
33+
34+type MockUIService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockUIService) EXPECT() *MockUIService_Expecter {
39+ return &MockUIService_Expecter{mock: &_m.Mock}
40+}
41+
42+// Action provides a mock function for the type MockUIService
43+func (_mock *MockUIService) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {
44+ ret := _mock.Called(ctx, request)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for Action")
48+ }
49+
50+ var r0 ports.UIActionResult
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIActionRequest) (ports.UIActionResult, error)); ok {
53+ return returnFunc(ctx, request)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIActionRequest) ports.UIActionResult); ok {
56+ r0 = returnFunc(ctx, request)
57+ } else {
58+ r0 = ret.Get(0).(ports.UIActionResult)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.UIActionRequest) error); ok {
61+ r1 = returnFunc(ctx, request)
62+ } else {
63+ r1 = ret.Error(1)
64+ }
65+ return r0, r1
66+}
67+
68+// MockUIService_Action_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Action'
69+type MockUIService_Action_Call struct {
70+ *mock.Call
71+}
72+
73+// Action is a helper method to define mock.On call
74+// - ctx context.Context
75+// - request ports.UIActionRequest
76+func (_e *MockUIService_Expecter) Action(ctx any, request any) *MockUIService_Action_Call {
77+ return &MockUIService_Action_Call{Call: _e.mock.On("Action", ctx, request)}
78+}
79+
80+func (_c *MockUIService_Action_Call) Run(run func(ctx context.Context, request ports.UIActionRequest)) *MockUIService_Action_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 ports.UIActionRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(ports.UIActionRequest)
89+ }
90+ run(
91+ arg0,
92+ arg1,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *MockUIService_Action_Call) Return(uIActionResult ports.UIActionResult, err error) *MockUIService_Action_Call {
99+ _c.Call.Return(uIActionResult, err)
100+ return _c
101+}
102+
103+func (_c *MockUIService_Action_Call) RunAndReturn(run func(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error)) *MockUIService_Action_Call {
104+ _c.Call.Return(run)
105+ return _c
106+}
107+
108+// Capture provides a mock function for the type MockUIService
109+func (_mock *MockUIService) Capture(attachment string) (ports.UISnapshot, error) {
110+ ret := _mock.Called(attachment)
111+
112+ if len(ret) == 0 {
113+ panic("no return value specified for Capture")
114+ }
115+
116+ var r0 ports.UISnapshot
117+ var r1 error
118+ if returnFunc, ok := ret.Get(0).(func(string) (ports.UISnapshot, error)); ok {
119+ return returnFunc(attachment)
120+ }
121+ if returnFunc, ok := ret.Get(0).(func(string) ports.UISnapshot); ok {
122+ r0 = returnFunc(attachment)
123+ } else {
124+ r0 = ret.Get(0).(ports.UISnapshot)
125+ }
126+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
127+ r1 = returnFunc(attachment)
128+ } else {
129+ r1 = ret.Error(1)
130+ }
131+ return r0, r1
132+}
133+
134+// MockUIService_Capture_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Capture'
135+type MockUIService_Capture_Call struct {
136+ *mock.Call
137+}
138+
139+// Capture is a helper method to define mock.On call
140+// - attachment string
141+func (_e *MockUIService_Expecter) Capture(attachment any) *MockUIService_Capture_Call {
142+ return &MockUIService_Capture_Call{Call: _e.mock.On("Capture", attachment)}
143+}
144+
145+func (_c *MockUIService_Capture_Call) Run(run func(attachment string)) *MockUIService_Capture_Call {
146+ _c.Call.Run(func(args mock.Arguments) {
147+ var arg0 string
148+ if args[0] != nil {
149+ arg0 = args[0].(string)
150+ }
151+ run(
152+ arg0,
153+ )
154+ })
155+ return _c
156+}
157+
158+func (_c *MockUIService_Capture_Call) Return(uISnapshot ports.UISnapshot, err error) *MockUIService_Capture_Call {
159+ _c.Call.Return(uISnapshot, err)
160+ return _c
161+}
162+
163+func (_c *MockUIService_Capture_Call) RunAndReturn(run func(attachment string) (ports.UISnapshot, error)) *MockUIService_Capture_Call {
164+ _c.Call.Return(run)
165+ return _c
166+}
167+
168+// Wait provides a mock function for the type MockUIService
169+func (_mock *MockUIService) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) {
170+ ret := _mock.Called(ctx, request)
171+
172+ if len(ret) == 0 {
173+ panic("no return value specified for Wait")
174+ }
175+
176+ var r0 ports.UIWaitResult
177+ var r1 error
178+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIWaitRequest) (ports.UIWaitResult, error)); ok {
179+ return returnFunc(ctx, request)
180+ }
181+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.UIWaitRequest) ports.UIWaitResult); ok {
182+ r0 = returnFunc(ctx, request)
183+ } else {
184+ r0 = ret.Get(0).(ports.UIWaitResult)
185+ }
186+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.UIWaitRequest) error); ok {
187+ r1 = returnFunc(ctx, request)
188+ } else {
189+ r1 = ret.Error(1)
190+ }
191+ return r0, r1
192+}
193+
194+// MockUIService_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait'
195+type MockUIService_Wait_Call struct {
196+ *mock.Call
197+}
198+
199+// Wait is a helper method to define mock.On call
200+// - ctx context.Context
201+// - request ports.UIWaitRequest
202+func (_e *MockUIService_Expecter) Wait(ctx any, request any) *MockUIService_Wait_Call {
203+ return &MockUIService_Wait_Call{Call: _e.mock.On("Wait", ctx, request)}
204+}
205+
206+func (_c *MockUIService_Wait_Call) Run(run func(ctx context.Context, request ports.UIWaitRequest)) *MockUIService_Wait_Call {
207+ _c.Call.Run(func(args mock.Arguments) {
208+ var arg0 context.Context
209+ if args[0] != nil {
210+ arg0 = args[0].(context.Context)
211+ }
212+ var arg1 ports.UIWaitRequest
213+ if args[1] != nil {
214+ arg1 = args[1].(ports.UIWaitRequest)
215+ }
216+ run(
217+ arg0,
218+ arg1,
219+ )
220+ })
221+ return _c
222+}
223+
224+func (_c *MockUIService_Wait_Call) Return(uIWaitResult ports.UIWaitResult, err error) *MockUIService_Wait_Call {
225+ _c.Call.Return(uIWaitResult, err)
226+ return _c
227+}
228+
229+func (_c *MockUIService_Wait_Call) RunAndReturn(run func(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error)) *MockUIService_Wait_Call {
230+ _c.Call.Return(run)
231+ return _c
232+}
233+
234+// NewMockUIObservationSink creates a new instance of MockUIObservationSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
235+// The first argument is typically a *testing.T value.
236+func NewMockUIObservationSink(t interface {
237+ mock.TestingT
238+ Cleanup(func())
239+}) *MockUIObservationSink {
240+ mock := &MockUIObservationSink{}
241+ mock.Mock.Test(t)
242+
243+ t.Cleanup(func() { mock.AssertExpectations(t) })
244+
245+ return mock
246+}
247+
248+// MockUIObservationSink is an autogenerated mock type for the UIObservationSink type
249+type MockUIObservationSink struct {
250+ mock.Mock
251+}
252+
253+type MockUIObservationSink_Expecter struct {
254+ mock *mock.Mock
255+}
256+
257+func (_m *MockUIObservationSink) EXPECT() *MockUIObservationSink_Expecter {
258+ return &MockUIObservationSink_Expecter{mock: &_m.Mock}
259+}
260+
261+// InvalidateTerminalObservation provides a mock function for the type MockUIObservationSink
262+func (_mock *MockUIObservationSink) InvalidateTerminalObservation() {
263+ _mock.Called()
264+ return
265+}
266+
267+// MockUIObservationSink_InvalidateTerminalObservation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InvalidateTerminalObservation'
268+type MockUIObservationSink_InvalidateTerminalObservation_Call struct {
269+ *mock.Call
270+}
271+
272+// InvalidateTerminalObservation is a helper method to define mock.On call
273+func (_e *MockUIObservationSink_Expecter) InvalidateTerminalObservation() *MockUIObservationSink_InvalidateTerminalObservation_Call {
274+ return &MockUIObservationSink_InvalidateTerminalObservation_Call{Call: _e.mock.On("InvalidateTerminalObservation")}
275+}
276+
277+func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) Run(run func()) *MockUIObservationSink_InvalidateTerminalObservation_Call {
278+ _c.Call.Run(func(args mock.Arguments) {
279+ run()
280+ })
281+ return _c
282+}
283+
284+func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) Return() *MockUIObservationSink_InvalidateTerminalObservation_Call {
285+ _c.Call.Return()
286+ return _c
287+}
288+
289+func (_c *MockUIObservationSink_InvalidateTerminalObservation_Call) RunAndReturn(run func()) *MockUIObservationSink_InvalidateTerminalObservation_Call {
290+ _c.Run(run)
291+ return _c
292+}
293+
294+// ObserveTerminalFlush provides a mock function for the type MockUIObservationSink
295+func (_mock *MockUIObservationSink) ObserveTerminalFlush() {
296+ _mock.Called()
297+ return
298+}
299+
300+// MockUIObservationSink_ObserveTerminalFlush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalFlush'
301+type MockUIObservationSink_ObserveTerminalFlush_Call struct {
302+ *mock.Call
303+}
304+
305+// ObserveTerminalFlush is a helper method to define mock.On call
306+func (_e *MockUIObservationSink_Expecter) ObserveTerminalFlush() *MockUIObservationSink_ObserveTerminalFlush_Call {
307+ return &MockUIObservationSink_ObserveTerminalFlush_Call{Call: _e.mock.On("ObserveTerminalFlush")}
308+}
309+
310+func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) Run(run func()) *MockUIObservationSink_ObserveTerminalFlush_Call {
311+ _c.Call.Run(func(args mock.Arguments) {
312+ run()
313+ })
314+ return _c
315+}
316+
317+func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) Return() *MockUIObservationSink_ObserveTerminalFlush_Call {
318+ _c.Call.Return()
319+ return _c
320+}
321+
322+func (_c *MockUIObservationSink_ObserveTerminalFlush_Call) RunAndReturn(run func()) *MockUIObservationSink_ObserveTerminalFlush_Call {
323+ _c.Run(run)
324+ return _c
325+}
326+
327+// ObserveTerminalResize provides a mock function for the type MockUIObservationSink
328+func (_mock *MockUIObservationSink) ObserveTerminalResize(geometry domain.Geometry) {
329+ _mock.Called(geometry)
330+ return
331+}
332+
333+// MockUIObservationSink_ObserveTerminalResize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalResize'
334+type MockUIObservationSink_ObserveTerminalResize_Call struct {
335+ *mock.Call
336+}
337+
338+// ObserveTerminalResize is a helper method to define mock.On call
339+// - geometry domain.Geometry
340+func (_e *MockUIObservationSink_Expecter) ObserveTerminalResize(geometry any) *MockUIObservationSink_ObserveTerminalResize_Call {
341+ return &MockUIObservationSink_ObserveTerminalResize_Call{Call: _e.mock.On("ObserveTerminalResize", geometry)}
342+}
343+
344+func (_c *MockUIObservationSink_ObserveTerminalResize_Call) Run(run func(geometry domain.Geometry)) *MockUIObservationSink_ObserveTerminalResize_Call {
345+ _c.Call.Run(func(args mock.Arguments) {
346+ var arg0 domain.Geometry
347+ if args[0] != nil {
348+ arg0 = args[0].(domain.Geometry)
349+ }
350+ run(
351+ arg0,
352+ )
353+ })
354+ return _c
355+}
356+
357+func (_c *MockUIObservationSink_ObserveTerminalResize_Call) Return() *MockUIObservationSink_ObserveTerminalResize_Call {
358+ _c.Call.Return()
359+ return _c
360+}
361+
362+func (_c *MockUIObservationSink_ObserveTerminalResize_Call) RunAndReturn(run func(geometry domain.Geometry)) *MockUIObservationSink_ObserveTerminalResize_Call {
363+ _c.Run(run)
364+ return _c
365+}
366+
367+// ObserveTerminalWrite provides a mock function for the type MockUIObservationSink
368+func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) {
369+ _mock.Called(append([]byte(nil), data...))
370+ return
371+}
372+
373+// MockUIObservationSink_ObserveTerminalWrite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveTerminalWrite'
374+type MockUIObservationSink_ObserveTerminalWrite_Call struct {
375+ *mock.Call
376+}
377+
378+// ObserveTerminalWrite is a helper method to define mock.On call
379+// - data []byte
380+func (_e *MockUIObservationSink_Expecter) ObserveTerminalWrite(data any) *MockUIObservationSink_ObserveTerminalWrite_Call {
381+ return &MockUIObservationSink_ObserveTerminalWrite_Call{Call: _e.mock.On("ObserveTerminalWrite", data)}
382+}
383+
384+func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) Run(run func(data []byte)) *MockUIObservationSink_ObserveTerminalWrite_Call {
385+ _c.Call.Run(func(args mock.Arguments) {
386+ var arg0 []byte
387+ if args[0] != nil {
388+ arg0 = args[0].([]byte)
389+ }
390+ run(
391+ arg0,
392+ )
393+ })
394+ return _c
395+}
396+
397+func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) Return() *MockUIObservationSink_ObserveTerminalWrite_Call {
398+ _c.Call.Return()
399+ return _c
400+}
401+
402+func (_c *MockUIObservationSink_ObserveTerminalWrite_Call) RunAndReturn(run func(data []byte)) *MockUIObservationSink_ObserveTerminalWrite_Call {
403+ _c.Run(run)
404+ return _c
405+}
406+
407+// NewMockUIOutputTransaction creates a new instance of MockUIOutputTransaction. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
408+// The first argument is typically a *testing.T value.
409+func NewMockUIOutputTransaction(t interface {
410+ mock.TestingT
411+ Cleanup(func())
412+}) *MockUIOutputTransaction {
413+ mock := &MockUIOutputTransaction{}
414+ mock.Mock.Test(t)
415+
416+ t.Cleanup(func() { mock.AssertExpectations(t) })
417+
418+ return mock
419+}
420+
421+// MockUIOutputTransaction is an autogenerated mock type for the UIOutputTransaction type
422+type MockUIOutputTransaction struct {
423+ mock.Mock
424+}
425+
426+type MockUIOutputTransaction_Expecter struct {
427+ mock *mock.Mock
428+}
429+
430+func (_m *MockUIOutputTransaction) EXPECT() *MockUIOutputTransaction_Expecter {
431+ return &MockUIOutputTransaction_Expecter{mock: &_m.Mock}
432+}
433+
434+// BeginOutput provides a mock function for the type MockUIOutputTransaction
435+func (_mock *MockUIOutputTransaction) BeginOutput(context1 ports.UIContext) {
436+ _mock.Called(context1)
437+ return
438+}
439+
440+// MockUIOutputTransaction_BeginOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BeginOutput'
441+type MockUIOutputTransaction_BeginOutput_Call struct {
442+ *mock.Call
443+}
444+
445+// BeginOutput is a helper method to define mock.On call
446+// - context1 ports.UIContext
447+func (_e *MockUIOutputTransaction_Expecter) BeginOutput(context1 any) *MockUIOutputTransaction_BeginOutput_Call {
448+ return &MockUIOutputTransaction_BeginOutput_Call{Call: _e.mock.On("BeginOutput", context1)}
449+}
450+
451+func (_c *MockUIOutputTransaction_BeginOutput_Call) Run(run func(context1 ports.UIContext)) *MockUIOutputTransaction_BeginOutput_Call {
452+ _c.Call.Run(func(args mock.Arguments) {
453+ var arg0 ports.UIContext
454+ if args[0] != nil {
455+ arg0 = args[0].(ports.UIContext)
456+ }
457+ run(
458+ arg0,
459+ )
460+ })
461+ return _c
462+}
463+
464+func (_c *MockUIOutputTransaction_BeginOutput_Call) Return() *MockUIOutputTransaction_BeginOutput_Call {
465+ _c.Call.Return()
466+ return _c
467+}
468+
469+func (_c *MockUIOutputTransaction_BeginOutput_Call) RunAndReturn(run func(context1 ports.UIContext)) *MockUIOutputTransaction_BeginOutput_Call {
470+ _c.Run(run)
471+ return _c
472+}
473+
474+// EndOutput provides a mock function for the type MockUIOutputTransaction
475+func (_mock *MockUIOutputTransaction) EndOutput(success bool) {
476+ _mock.Called(success)
477+ return
478+}
479+
480+// MockUIOutputTransaction_EndOutput_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EndOutput'
481+type MockUIOutputTransaction_EndOutput_Call struct {
482+ *mock.Call
483+}
484+
485+// EndOutput is a helper method to define mock.On call
486+// - success bool
487+func (_e *MockUIOutputTransaction_Expecter) EndOutput(success any) *MockUIOutputTransaction_EndOutput_Call {
488+ return &MockUIOutputTransaction_EndOutput_Call{Call: _e.mock.On("EndOutput", success)}
489+}
490+
491+func (_c *MockUIOutputTransaction_EndOutput_Call) Run(run func(success bool)) *MockUIOutputTransaction_EndOutput_Call {
492+ _c.Call.Run(func(args mock.Arguments) {
493+ var arg0 bool
494+ if args[0] != nil {
495+ arg0 = args[0].(bool)
496+ }
497+ run(
498+ arg0,
499+ )
500+ })
501+ return _c
502+}
503+
504+func (_c *MockUIOutputTransaction_EndOutput_Call) Return() *MockUIOutputTransaction_EndOutput_Call {
505+ _c.Call.Return()
506+ return _c
507+}
508+
509+func (_c *MockUIOutputTransaction_EndOutput_Call) RunAndReturn(run func(success bool)) *MockUIOutputTransaction_EndOutput_Call {
510+ _c.Run(run)
511+ return _c
512+}
513+
514+// PublishContext provides a mock function for the type MockUIOutputTransaction
515+func (_mock *MockUIOutputTransaction) PublishContext(context1 ports.UIContext) error {
516+ ret := _mock.Called(context1)
517+
518+ if len(ret) == 0 {
519+ panic("no return value specified for PublishContext")
520+ }
521+
522+ var r0 error
523+ if returnFunc, ok := ret.Get(0).(func(ports.UIContext) error); ok {
524+ r0 = returnFunc(context1)
525+ } else {
526+ r0 = ret.Error(0)
527+ }
528+ return r0
529+}
530+
531+// MockUIOutputTransaction_PublishContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PublishContext'
532+type MockUIOutputTransaction_PublishContext_Call struct {
533+ *mock.Call
534+}
535+
536+// PublishContext is a helper method to define mock.On call
537+// - context1 ports.UIContext
538+func (_e *MockUIOutputTransaction_Expecter) PublishContext(context1 any) *MockUIOutputTransaction_PublishContext_Call {
539+ return &MockUIOutputTransaction_PublishContext_Call{Call: _e.mock.On("PublishContext", context1)}
540+}
541+
542+func (_c *MockUIOutputTransaction_PublishContext_Call) Run(run func(context1 ports.UIContext)) *MockUIOutputTransaction_PublishContext_Call {
543+ _c.Call.Run(func(args mock.Arguments) {
544+ var arg0 ports.UIContext
545+ if args[0] != nil {
546+ arg0 = args[0].(ports.UIContext)
547+ }
548+ run(
549+ arg0,
550+ )
551+ })
552+ return _c
553+}
554+
555+func (_c *MockUIOutputTransaction_PublishContext_Call) Return(err error) *MockUIOutputTransaction_PublishContext_Call {
556+ _c.Call.Return(err)
557+ return _c
558+}
559+
560+func (_c *MockUIOutputTransaction_PublishContext_Call) RunAndReturn(run func(context1 ports.UIContext) error) *MockUIOutputTransaction_PublishContext_Call {
561+ _c.Call.Return(run)
562+ return _c
563+}
564+
565+// NewMockUIState creates a new instance of MockUIState. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
566+// The first argument is typically a *testing.T value.
567+func NewMockUIState(t interface {
568+ mock.TestingT
569+ Cleanup(func())
570+}) *MockUIState {
571+ mock := &MockUIState{}
572+ mock.Mock.Test(t)
573+
574+ t.Cleanup(func() { mock.AssertExpectations(t) })
575+
576+ return mock
577+}
578+
579+// MockUIState is an autogenerated mock type for the UIState type
580+type MockUIState struct {
581+ mock.Mock
582+}
583+
584+type MockUIState_Expecter struct {
585+ mock *mock.Mock
586+}
587+
588+func (_m *MockUIState) EXPECT() *MockUIState_Expecter {
589+ return &MockUIState_Expecter{mock: &_m.Mock}
590+}
591+
592+// Changes provides a mock function for the type MockUIState
593+func (_mock *MockUIState) Changes() <-chan struct{} {
594+ ret := _mock.Called()
595+
596+ if len(ret) == 0 {
597+ panic("no return value specified for Changes")
598+ }
599+
600+ var r0 <-chan struct{}
601+ if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
602+ r0 = returnFunc()
603+ } else {
604+ if ret.Get(0) != nil {
605+ r0 = ret.Get(0).(<-chan struct{})
606+ }
607+ }
608+ return r0
609+}
610+
611+// MockUIState_Changes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changes'
612+type MockUIState_Changes_Call struct {
613+ *mock.Call
614+}
615+
616+// Changes is a helper method to define mock.On call
617+func (_e *MockUIState_Expecter) Changes() *MockUIState_Changes_Call {
618+ return &MockUIState_Changes_Call{Call: _e.mock.On("Changes")}
619+}
620+
621+func (_c *MockUIState_Changes_Call) Run(run func()) *MockUIState_Changes_Call {
622+ _c.Call.Run(func(args mock.Arguments) {
623+ run()
624+ })
625+ return _c
626+}
627+
628+func (_c *MockUIState_Changes_Call) Return(valCh <-chan struct{}) *MockUIState_Changes_Call {
629+ _c.Call.Return(valCh)
630+ return _c
631+}
632+
633+func (_c *MockUIState_Changes_Call) RunAndReturn(run func() <-chan struct{}) *MockUIState_Changes_Call {
634+ _c.Call.Return(run)
635+ return _c
636+}
637+
638+// Snapshot provides a mock function for the type MockUIState
639+func (_mock *MockUIState) Snapshot() (ports.UISnapshot, error) {
640+ ret := _mock.Called()
641+
642+ if len(ret) == 0 {
643+ panic("no return value specified for Snapshot")
644+ }
645+
646+ var r0 ports.UISnapshot
647+ var r1 error
648+ if returnFunc, ok := ret.Get(0).(func() (ports.UISnapshot, error)); ok {
649+ return returnFunc()
650+ }
651+ if returnFunc, ok := ret.Get(0).(func() ports.UISnapshot); ok {
652+ r0 = returnFunc()
653+ } else {
654+ r0 = ret.Get(0).(ports.UISnapshot)
655+ }
656+ if returnFunc, ok := ret.Get(1).(func() error); ok {
657+ r1 = returnFunc()
658+ } else {
659+ r1 = ret.Error(1)
660+ }
661+ return r0, r1
662+}
663+
664+// MockUIState_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
665+type MockUIState_Snapshot_Call struct {
666+ *mock.Call
667+}
668+
669+// Snapshot is a helper method to define mock.On call
670+func (_e *MockUIState_Expecter) Snapshot() *MockUIState_Snapshot_Call {
671+ return &MockUIState_Snapshot_Call{Call: _e.mock.On("Snapshot")}
672+}
673+
674+func (_c *MockUIState_Snapshot_Call) Run(run func()) *MockUIState_Snapshot_Call {
675+ _c.Call.Run(func(args mock.Arguments) {
676+ run()
677+ })
678+ return _c
679+}
680+
681+func (_c *MockUIState_Snapshot_Call) Return(uISnapshot ports.UISnapshot, err error) *MockUIState_Snapshot_Call {
682+ _c.Call.Return(uISnapshot, err)
683+ return _c
684+}
685+
686+func (_c *MockUIState_Snapshot_Call) RunAndReturn(run func() (ports.UISnapshot, error)) *MockUIState_Snapshot_Call {
687+ _c.Call.Return(run)
688+ return _c
689+}
A internal/ports/ui.go +212 -0
@@ -0,0 +1,212 @@
1+package ports
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+)
11+
12+// UIPresentationStatus describes what the client is currently presenting.
13+type UIPresentationStatus string
14+
15+const (
16+ UIStatusAttached UIPresentationStatus = "attached"
17+ UIStatusTransitioning UIPresentationStatus = "transitioning"
18+ UIStatusReconnecting UIPresentationStatus = "reconnecting"
19+ UIStatusDetached UIPresentationStatus = "detached"
20+)
21+
22+// UIColorKind identifies how a terminal color is represented.
23+type UIColorKind uint8
24+
25+const (
26+ UIColorDefault UIColorKind = iota
27+ UIColorIndexed
28+ UIColorRGB
29+)
30+
31+// UIColor is an owned terminal color value.
32+type UIColor struct {
33+ Kind UIColorKind
34+ Index uint8
35+ R uint8
36+ G uint8
37+ B uint8
38+}
39+
40+// UIStyle is the rendered style of one terminal cell.
41+type UIStyle struct {
42+ Foreground UIColor
43+ Background UIColor
44+ Bold bool
45+ Dim bool
46+ Italic bool
47+ Underline bool
48+ UnderlineStyle uint8
49+ Blink bool
50+ Inverse bool
51+ Strikethrough bool
52+}
53+
54+// UICell is one row-major viewport cell. Continuation cells have Width zero.
55+type UICell struct {
56+ Text string
57+ Width int
58+ Continuation bool
59+ Style UIStyle
60+}
61+
62+// UICursor is the cursor captured with a viewport revision.
63+type UICursor struct {
64+ Row int
65+ Column int
66+ Visible bool
67+ Style int
68+ StyleSet bool
69+}
70+
71+// UIContext binds rendered cells to client and daemon semantic state.
72+type UIContext struct {
73+ AttachmentHandle string
74+ Generation uint64
75+ Route protocol.CommittedRouteIdentity
76+ TabID domain.TabStableID
77+ FocusedPaneID domain.PaneStableID
78+ OutputEpoch uint64
79+ OutputState uint64
80+ ViewRevision uint64
81+ ViewPublication uint64
82+ Status UIPresentationStatus
83+}
84+
85+// UISnapshot is an immutable, owned viewport publication.
86+type UISnapshot struct {
87+ Revision uint64
88+ Context UIContext
89+ Columns int
90+ Rows int
91+ Cursor UICursor
92+ Cells []UICell
93+ AutoWrap bool
94+ ApplicationCursor bool
95+}
96+
97+// UIErrorCode is a stable machine-readable API error value.
98+type UIErrorCode string
99+
100+// UI error codes are stable API values shared by client ownership and adapters.
101+const (
102+ UIErrInvalidRequest UIErrorCode = "invalid_request"
103+ UIErrUnsupportedVersion UIErrorCode = "unsupported_version"
104+ UIErrPermissionDenied UIErrorCode = "permission_denied"
105+ UIErrStaleAttachment UIErrorCode = "stale_attachment"
106+ UIErrUnavailable UIErrorCode = "unavailable"
107+ UIErrTimeout UIErrorCode = "timeout"
108+ UIErrOutcomeUnknown UIErrorCode = "outcome_unknown"
109+ UIErrCaptureTooLarge UIErrorCode = "capture_too_large"
110+ UIErrActionExpired UIErrorCode = "action_expired"
111+ UIErrInputBusy UIErrorCode = "input_busy"
112+ UIErrBusy UIErrorCode = "busy"
113+ UIErrNavigationFailed UIErrorCode = "navigation_failed"
114+ UIErrEndpointNotConfigured UIErrorCode = "endpoint_not_configured"
115+)
116+
117+// UIError reports a sanitized operation failure without captured content.
118+type UIError struct {
119+ Code UIErrorCode
120+ Accepted bool
121+ ActionID uint64
122+}
123+
124+func (e *UIError) Error() string { return string(e.Code) }
125+
126+// UIActionRequest contains one validated operation, not raw terminal bytes.
127+type UIActionRequest struct {
128+ Attachment string
129+ Generation uint64
130+ Keys []string
131+ Text string
132+ Timeout time.Duration
133+}
134+
135+// UIActionStatus is the lifecycle of one admitted automation action.
136+type UIActionStatus string
137+
138+// UI action statuses are stable API values, separate from presentation status.
139+const (
140+ UIActionPending UIActionStatus = "pending"
141+ UIActionProcessed UIActionStatus = "processed"
142+ UIActionOutcomeUnknown UIActionStatus = UIActionStatus(UIErrOutcomeUnknown)
143+ UIActionNavigationFailed UIActionStatus = UIActionStatus(UIErrNavigationFailed)
144+ UIActionUnavailable UIActionStatus = UIActionStatus(UIErrUnavailable)
145+)
146+
147+type UIActionResult struct {
148+ ActionID uint64
149+ Accepted bool
150+ Status UIActionStatus
151+ Revision uint64
152+ Context UIContext
153+}
154+
155+type UIExpect struct {
156+ TextContains *string
157+ Session *protocol.ExactSessionTarget
158+ Focus *UIFocus
159+ Status *UIPresentationStatus
160+}
161+
162+type UIFocus struct {
163+ TabID domain.TabStableID
164+ PaneID domain.PaneStableID
165+}
166+
167+type UIWaitRequest struct {
168+ Attachment string
169+ AfterAction uint64
170+ Expect UIExpect
171+ Timeout time.Duration
172+}
173+
174+type UIWaitResult struct {
175+ ActionID uint64
176+ ActionStatus UIActionStatus
177+ Revision uint64
178+ Context UIContext
179+}
180+
181+// UIService is attachment-scoped. Implementations bound actions and waits;
182+// adapters own permissions, decoding, socket quotas and response serialization.
183+type UIService interface {
184+ Capture(attachment string) (UISnapshot, error)
185+ Action(ctx context.Context, request UIActionRequest) (UIActionResult, error)
186+ Wait(ctx context.Context, request UIWaitRequest) (UIWaitResult, error)
187+}
188+
189+var ErrUIUnavailable = errors.New("UI capture unavailable")
190+
191+// UIObservationSink receives successfully emitted terminal bytes and flush
192+// boundaries. Implementations must copy bytes before returning and never block.
193+type UIObservationSink interface {
194+ ObserveTerminalWrite(data []byte)
195+ ObserveTerminalFlush()
196+ ObserveTerminalResize(geometry domain.Geometry)
197+ InvalidateTerminalObservation()
198+}
199+
200+// UIOutputTransaction binds terminal output to coherent semantic context.
201+type UIOutputTransaction interface {
202+ BeginOutput(context UIContext)
203+ EndOutput(success bool)
204+ PublishContext(context UIContext) error
205+}
206+
207+// UIState exposes the latest immutable publication and a coalesced change
208+// signal. Snapshot returns ErrUIUnavailable rather than stale state.
209+type UIState interface {
210+ Snapshot() (UISnapshot, error)
211+ Changes() <-chan struct{}
212+}
M internal/protocol/messages.go +3 -0
@@ -26,6 +26,7 @@ func (ParkedRouteRequest) clientMessage() {}
26 func (RecentRouteSnapshot) clientMessage() {}26 func (RecentRouteSnapshot) clientMessage() {}
27 func (RouteNavigationFailure) clientMessage() {}27 func (RouteNavigationFailure) clientMessage() {}
28 func (SessionCreationFailure) clientMessage() {}28 func (SessionCreationFailure) clientMessage() {}
29+func (UIFence) clientMessage() {}
29 30
30 func (Welcome) serverMessage() {}31 func (Welcome) serverMessage() {}
31 func (ErrorMsg) serverMessage() {}32 func (ErrorMsg) serverMessage() {}
@@ -44,3 +45,5 @@ func (RouteNavigationFailure) serverMessage() {}
44 func (RoutePosition) serverMessage() {}45 func (RoutePosition) serverMessage() {}
45 func (SamePeerSwitchFailure) serverMessage() {}46 func (SamePeerSwitchFailure) serverMessage() {}
46 func (ParkedRouteResponse) serverMessage() {}47 func (ParkedRouteResponse) serverMessage() {}
48+func (UIReceipt) serverMessage() {}
49+func (UIViewUpdate) serverMessage() {}
M internal/protocol/navigation.go +3 -2
@@ -28,8 +28,9 @@ type ParkedRouteLeaseID [16]byte
28 func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }28 func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
29 29
30 type NavigationDirective struct {30 type NavigationDirective struct {
31- Action NavigationAction31+ CauseActionID uint64
32- LeaseID ParkedRouteLeaseID32+ Action NavigationAction
33+ LeaseID ParkedRouteLeaseID
33 }34 }
34 35
35 type ParkedRouteAction uint836 type ParkedRouteAction uint8
M internal/protocol/routes.go +2 -0
@@ -229,6 +229,7 @@ type RecentRouteSnapshot struct {
229 // RouteNavigationAction asks the client to resolve one entry from a specific229 // RouteNavigationAction asks the client to resolve one entry from a specific
230 // complete snapshot. It carries no session name, endpoint, or credential.230 // complete snapshot. It carries no session name, endpoint, or credential.
231 type RouteNavigationAction struct {231 type RouteNavigationAction struct {
232+ CauseActionID uint64
232 SnapshotGeneration uint64233 SnapshotGeneration uint64
233 Key uint64234 Key uint64
234 Generation uint64235 Generation uint64
@@ -237,6 +238,7 @@ type RouteNavigationAction struct {
237 // RouteCreateSessionAction asks the client to create a named session through238 // RouteCreateSessionAction asks the client to create a named session through
238 // one exact route authority from the latest complete snapshot.239 // one exact route authority from the latest complete snapshot.
239 type RouteCreateSessionAction struct {240 type RouteCreateSessionAction struct {
241+ CauseActionID uint64
240 RequestID uint64242 RequestID uint64
241 SnapshotGeneration uint64243 SnapshotGeneration uint64
242 Key uint64244 Key uint64
M internal/protocol/session.go +77 -6
@@ -13,15 +13,16 @@ 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 = 4016+const Version uint16 = 41
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.
20 const HandshakeTimeout = 15 * time.Second20 const HandshakeTimeout = 15 * time.Second
21 21
22-// MaxOutputDataLen is the largest decoded terminal byte stream accepted by one22+// MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
23-// output publication in protocol version 37.23+// valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab
24-const MaxOutputDataLen = (16 << 20) - 5524+// and pane IDs, including string lengths) within the 16 MiB frame limit.
25+const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128)
25 26
26 // MaxOutputWindow caps output states sent before client acknowledgement.27 // MaxOutputWindow caps output states sent before client acknowledgement.
27 const MaxOutputWindow = 828 const MaxOutputWindow = 8
@@ -122,6 +123,7 @@ func (h Hello) Geometry() domain.Geometry {
122 123
123 type Input struct {124 type Input struct {
124 InputSeq uint64125 InputSeq uint64
126+ ActionID uint64
125 Data []byte127 Data []byte
126 }128 }
127 129
@@ -185,6 +187,26 @@ type ErrorMsg struct {
185 Text string187 Text string
186 }188 }
187 189
190+type ViewContext struct {
191+ Publication uint64
192+ Route CommittedRouteIdentity
193+ TabID domain.TabStableID
194+ FocusedPaneID domain.PaneStableID
195+}
196+
197+func (c ViewContext) Validate() error {
198+ if c.Publication == 0 {
199+ return errors.New("invalid view publication")
200+ }
201+ if err := c.Route.Validate(); err != nil {
202+ return err
203+ }
204+ if err := domain.ValidateTabStableID(c.TabID); err != nil {
205+ return err
206+ }
207+ return domain.ValidatePaneStableID(c.FocusedPaneID)
208+}
209+
188 type Output struct {210 type Output struct {
189 Epoch uint64211 Epoch uint64
190 Base uint64212 Base uint64
@@ -193,10 +215,54 @@ type Output struct {
193 ViewRevision uint64215 ViewRevision uint64
194 Size domain.Size216 Size domain.Size
195 Full bool217 Full bool
218+ Context *ViewContext
196 Data []byte219 Data []byte
197 }220 }
198 221
222+type UIFence struct{ ActionID uint64 }
223+
224+type UIReceiptOutcome uint8
225+
226+const (
227+ UIReceiptProcessed UIReceiptOutcome = iota + 1
228+ UIReceiptUnavailable
229+)
230+
231+type UIReceipt struct {
232+ ActionID uint64
233+ Epoch uint64
234+ State uint64
235+ ViewPublication uint64
236+ Outcome UIReceiptOutcome
237+}
238+
239+// Validate requires a committed publication for success. Unavailable receipts
240+// carry no boundary: expiry cannot claim that any captured view was processed.
241+func (r UIReceipt) Validate() error {
242+ if r.ActionID == 0 {
243+ return errors.New("UI receipt requires an action ID")
244+ }
245+ switch r.Outcome {
246+ case UIReceiptProcessed:
247+ if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
248+ return nil
249+ }
250+ case UIReceiptUnavailable:
251+ if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
252+ return nil
253+ }
254+ }
255+ return errors.New("invalid UI receipt boundary or outcome")
256+}
257+
258+type UIViewUpdate struct {
259+ Epoch uint64
260+ State uint64
261+ Context ViewContext
262+}
263+
199 type AttachTarget struct {264 type AttachTarget struct {
265+ CauseActionID uint64
200 RequestID uint64266 RequestID uint64
201 Endpoint string267 Endpoint string
202 Session string268 Session string
@@ -355,15 +421,20 @@ func ValidateOutput(m Output) error {
355 return ErrInvalidOutput421 return ErrInvalidOutput
356 }422 }
357 if m.New == 0 {423 if m.New == 0 {
358- if m.Base != 0 || m.Full {424+ if m.Base != 0 || m.Full || m.Context != nil {
359 return ErrInvalidOutput425 return ErrInvalidOutput
360 }426 }
361- } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {427+ } else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
362 return ErrInvalidOutput428 return ErrInvalidOutput
363 }429 }
364 if err := ValidateSize(m.Size); err != nil {430 if err := ValidateSize(m.Size); err != nil {
365 return fmt.Errorf("%w: size", ErrInvalidOutput)431 return fmt.Errorf("%w: size", ErrInvalidOutput)
366 }432 }
433+ if m.Context != nil {
434+ if err := m.Context.Validate(); err != nil {
435+ return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
436+ }
437+ }
367 if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {438 if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
368 return ErrInvalidOutput439 return ErrInvalidOutput
369 }440 }
M internal/protocol/session_test.go +8 -3
@@ -10,8 +10,8 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(40), Version)13+ require.Equal(t, uint16(41), Version)
14- require.Equal(t, (16<<20)-55, MaxOutputDataLen)14+ require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
17 func TestHelloSemanticValidation(t *testing.T) {17 func TestHelloSemanticValidation(t *testing.T) {
@@ -44,7 +44,12 @@ func TestHelloSemanticValidation(t *testing.T) {
44 }44 }
45 45
46 func TestOutputSemanticValidation(t *testing.T) {46 func TestOutputSemanticValidation(t *testing.T) {
47- valid := Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}}47+ context := ViewContext{
48+ Publication: 1,
49+ Route: CommittedRouteIdentity{Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
50+ TabID: "tab-1", FocusedPaneID: "pane-1",
51+ }
52+ valid := Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: &context}
48 require.NoError(t, ValidateOutput(valid))53 require.NoError(t, ValidateOutput(valid))
49 54
50 invalid := valid55 invalid := valid
A internal/protocol/ui_receipt_test.go +26 -0
@@ -0,0 +1,26 @@
1+package protocol
2+
3+import "testing"
4+
5+func TestUIReceiptValidation(t *testing.T) {
6+ for _, test := range []struct {
7+ name string
8+ receipt UIReceipt
9+ valid bool
10+ }{
11+ {"processed", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: UIReceiptProcessed}, true},
12+ {"unavailable", UIReceipt{ActionID: 1, Outcome: UIReceiptUnavailable}, true},
13+ {"zero action", UIReceipt{Outcome: UIReceiptUnavailable}, false},
14+ {"zero epoch", UIReceipt{ActionID: 1, State: 3, ViewPublication: 4, Outcome: UIReceiptProcessed}, false},
15+ {"zero state", UIReceipt{ActionID: 1, Epoch: 2, ViewPublication: 4, Outcome: UIReceiptProcessed}, false},
16+ {"zero publication", UIReceipt{ActionID: 1, Epoch: 2, State: 3, Outcome: UIReceiptProcessed}, false},
17+ {"unknown outcome", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: 3}, false},
18+ {"unavailable invents boundary", UIReceipt{ActionID: 1, Epoch: 2, State: 3, ViewPublication: 4, Outcome: UIReceiptUnavailable}, false},
19+ } {
20+ t.Run(test.name, func(t *testing.T) {
21+ if err := test.receipt.Validate(); (err == nil) != test.valid {
22+ t.Fatalf("Validate() = %v, want valid=%v", err, test.valid)
23+ }
24+ })
25+ }
26+}
M internal/protocol/wire/attach_target_tab_test.go +2 -1
@@ -19,6 +19,7 @@ func TestAttachTargetPreferredTabStrict(t *testing.T) {
19 want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1)19 want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1)
20 want = append(want, make([]byte, 15)...)20 want = append(want, make([]byte, 15)...)
21 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b')21 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b')
22+ want = append(want, make([]byte, 8)...)
22 require.Equal(t, want, payload)23 require.Equal(t, want, payload)
23 decoded, err := UnmarshalAttachTarget(payload)24 decoded, err := UnmarshalAttachTarget(payload)
24 require.NoError(t, err)25 require.NoError(t, err)
@@ -26,7 +27,7 @@ func TestAttachTargetPreferredTabStrict(t *testing.T) {
26 assertAllPrefixesFail(t, payload, UnmarshalAttachTarget)27 assertAllPrefixesFail(t, payload, UnmarshalAttachTarget)
27 assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget)28 assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget)
28 malformed := append([]byte(nil), payload...)29 malformed := append([]byte(nil), payload...)
29- malformed[len(malformed)-1] = '\n'30+ malformed[len(malformed)-9] = '\n'
30 _, err = UnmarshalAttachTarget(malformed)31 _, err = UnmarshalAttachTarget(malformed)
31 require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget)32 require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget)
32 33
M internal/protocol/wire/frame.go +3 -0
@@ -30,6 +30,7 @@ const (
30 MsgSamePeerSwitchRequest MsgType = 3330 MsgSamePeerSwitchRequest MsgType = 33
31 MsgParkedRouteRequest MsgType = 3531 MsgParkedRouteRequest MsgType = 35
32 MsgSessionCreationFailure MsgType = 3732 MsgSessionCreationFailure MsgType = 37
33+ MsgUIFence MsgType = 39
33 34
34 MsgWelcome MsgType = 1635 MsgWelcome MsgType = 16
35 MsgError MsgType = 1736 MsgError MsgType = 17
@@ -49,6 +50,8 @@ const (
49 MsgSamePeerSwitchFailure MsgType = 3450 MsgSamePeerSwitchFailure MsgType = 34
50 MsgParkedRouteResponse MsgType = 3651 MsgParkedRouteResponse MsgType = 36
51 MsgRouteCreateSession MsgType = 3852 MsgRouteCreateSession MsgType = 38
53+ MsgUIReceipt MsgType = 40
54+ MsgUIViewUpdate MsgType = 41
52 )55 )
53 56
54 // Frame is the unit of exchange over a Transport: a typed, length-delimited57 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/protocol/wire/frame_test.go +2 -2
@@ -7,8 +7,8 @@ import (
7 )7 )
8 8
9 func TestProtocolVersion(t *testing.T) {9 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 40 {10+ if protocol.Version != 41 {
11- t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)11+ t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version)
12 }12 }
13 }13 }
14 14
M internal/protocol/wire/navigation_test.go +1 -1
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
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},153 {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false},
154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},154 {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},
155 {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false},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: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"},156+ {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002902" + "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},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},158 {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false},
159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},159 {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
M internal/protocol/wire/output_compression_test.go +13 -6
@@ -29,11 +29,13 @@ func TestOutputCompression(t *testing.T) {
29 {name: "small full snapshot", output: protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("small")}, wantKind: outputCompressionNone},29 {name: "small full snapshot", output: protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("small")}, wantKind: outputCompressionNone},
30 } {30 } {
31 t.Run(tt.name, func(t *testing.T) {31 t.Run(tt.name, func(t *testing.T) {
32+ context := testViewContext()
33+ tt.output.Context = &context
32 payload, err := MarshalOutput(tt.output)34 payload, err := MarshalOutput(tt.output)
33 if err != nil {35 if err != nil {
34 t.Fatal(err)36 t.Fatal(err)
35 }37 }
36- if got := payload[outputHeaderLen]; got != tt.wantKind {38+ if got := payload[outputHeaderLen+len(testViewContextGolden())]; got != tt.wantKind {
37 t.Fatalf("compression kind = %d, want %d", got, tt.wantKind)39 t.Fatalf("compression kind = %d, want %d", got, tt.wantKind)
38 }40 }
39 decoded, err := UnmarshalOutput(payload)41 decoded, err := UnmarshalOutput(payload)
@@ -52,11 +54,14 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
52 Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 120, Rows: 40}, Full: true,54 Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 120, Rows: 40}, Full: true,
53 Data: bytes.Repeat([]byte("\x1b[31mcompressed\x1b[0m\r\n"), 256),55 Data: bytes.Repeat([]byte("\x1b[31mcompressed\x1b[0m\r\n"), 256),
54 }56 }
57+ context := testViewContext()
58+ output.Context = &context
55 payload, err := MarshalOutput(output)59 payload, err := MarshalOutput(output)
56 if err != nil {60 if err != nil {
57 t.Fatal(err)61 t.Fatal(err)
58 }62 }
59- if payload[outputHeaderLen] != outputCompressionZlib {63+ compressionOffset := outputHeaderLen + len(testViewContextGolden())
64+ if payload[compressionOffset] != outputCompressionZlib {
60 t.Fatal("test fixture was not compressed")65 t.Fatal("test fixture was not compressed")
61 }66 }
62 67
@@ -64,12 +69,12 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
64 name string69 name string
65 mutate func([]byte)70 mutate func([]byte)
66 }{71 }{
67- {name: "unknown kind", mutate: func(b []byte) { b[outputHeaderLen] = 99 }},72+ {name: "unknown kind", mutate: func(b []byte) { b[compressionOffset] = 99 }},
68 {name: "decoded length mismatch", mutate: func(b []byte) {73 {name: "decoded length mismatch", mutate: func(b []byte) {
69- binary.BigEndian.PutUint32(b[outputHeaderLen+1:outputHeaderLen+5], uint32(len(output.Data)-1))74+ binary.BigEndian.PutUint32(b[compressionOffset+1:compressionOffset+5], uint32(len(output.Data)-1))
70 }},75 }},
71 {name: "compressed incremental", mutate: func(b []byte) {76 {name: "compressed incremental", mutate: func(b []byte) {
72- b[outputHeaderLen-1] = 077+ b[44] = 0 // Full; byte45 is context presence, not the reset flag.
73 binary.BigEndian.PutUint64(b[16:24], 2)78 binary.BigEndian.PutUint64(b[16:24], 2)
74 binary.BigEndian.PutUint64(b[8:16], 1)79 binary.BigEndian.PutUint64(b[8:16], 1)
75 }},80 }},
@@ -84,7 +89,7 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
84 })89 })
85 }90 }
86 trailingCompressed := append(append([]byte(nil), payload...), 0)91 trailingCompressed := append(append([]byte(nil), payload...), 0)
87- binary.BigEndian.PutUint32(trailingCompressed[outputHeaderLen+5:outputHeaderLen+9], uint32(len(trailingCompressed)-(outputHeaderLen+9)))92+ binary.BigEndian.PutUint32(trailingCompressed[compressionOffset+5:compressionOffset+9], uint32(len(trailingCompressed)-(compressionOffset+9)))
88 if _, err := UnmarshalOutput(trailingCompressed); err == nil {93 if _, err := UnmarshalOutput(trailingCompressed); err == nil {
89 t.Fatal("UnmarshalOutput accepted trailing compressed bytes")94 t.Fatal("UnmarshalOutput accepted trailing compressed bytes")
90 }95 }
@@ -108,6 +113,8 @@ func BenchmarkMarshalOutput(b *testing.B) {
108 },113 },
109 }114 }
110 for name, output := range fixtures {115 for name, output := range fixtures {
116+ context := testViewContext()
117+ output.Context = &context
111 b.Run(name, func(b *testing.B) {118 b.Run(name, func(b *testing.B) {
112 b.ReportAllocs()119 b.ReportAllocs()
113 b.SetBytes(int64(len(output.Data)))120 b.SetBytes(int64(len(output.Data)))
A internal/protocol/wire/output_context_test.go +101 -0
@@ -0,0 +1,101 @@
1+package wire
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestOutputContextRoundTripStrict(t *testing.T) {
14+ for _, full := range []bool{false, true} {
15+ name := "delta"
16+ if full {
17+ name = "compressed full"
18+ }
19+ t.Run(name, func(t *testing.T) {
20+ context := testViewContext()
21+ output := protocol.Output{Epoch: 2, Base: 1, New: 2, Echo: 9, ViewRevision: 4,
22+ Size: domain.Size{Cols: 80, Rows: 24}, Context: &context, Data: []byte("view")}
23+ if full {
24+ output.Base, output.New, output.Full = 0, 1, true
25+ output.Data = bytes.Repeat([]byte("view"), 1024)
26+ }
27+ encoded, err := MarshalOutput(output)
28+ require.NoError(t, err)
29+ decoded, err := UnmarshalOutput(encoded)
30+ require.NoError(t, err)
31+ require.Equal(t, output, decoded)
32+ for size := range len(encoded) {
33+ _, err := UnmarshalOutput(encoded[:size])
34+ require.Error(t, err, "prefix %d", size)
35+ }
36+ _, err = UnmarshalOutput(append(append([]byte(nil), encoded...), 0))
37+ require.Error(t, err)
38+ })
39+ }
40+}
41+
42+func TestOutputContextFitsMaximumFrame(t *testing.T) {
43+ context := testViewContext()
44+ context.Route.Target.SessionName = strings.Repeat("s", 64)
45+ context.TabID = domain.TabStableID(strings.Repeat("t", 128))
46+ context.FocusedPaneID = domain.PaneStableID(strings.Repeat("p", 128))
47+ output := protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context,
48+ Data: make([]byte, protocol.MaxOutputDataLen)}
49+ encoded, err := MarshalOutput(output)
50+ require.NoError(t, err)
51+ require.Equal(t, MaxFrameLen-1, len(encoded), "type byte also consumes frame capacity")
52+ decoded, err := UnmarshalOutput(encoded)
53+ require.NoError(t, err)
54+ require.Equal(t, output, decoded)
55+ output.Data = append(output.Data, 0)
56+ _, err = MarshalOutput(output)
57+ require.ErrorIs(t, err, protocol.ErrInvalidOutput)
58+}
59+
60+func TestOutputRequiresSemanticContext(t *testing.T) {
61+ context := testViewContext()
62+ output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context}
63+ valid, err := MarshalOutput(output)
64+ require.NoError(t, err)
65+ // Remove the semantic block without changing replay/data fields. Decoder
66+ // validation must reject contextless state output before allocating data.
67+ contextBytes := payloadWriter{}
68+ require.NoError(t, marshalViewContext(&contextBytes, context))
69+ missing := append(append([]byte(nil), valid[:outputHeaderLen]...), valid[outputHeaderLen+len(contextBytes.b):]...)
70+ missing[outputHeaderLen-1] = 0
71+ _, err = UnmarshalOutput(missing)
72+ require.ErrorIs(t, err, protocol.ErrInvalidOutput)
73+ output.Context = nil
74+ require.ErrorIs(t, protocol.ValidateOutput(output), protocol.ErrInvalidOutput)
75+ _, err = MarshalOutput(output)
76+ require.ErrorIs(t, err, protocol.ErrInvalidOutput)
77+}
78+
79+func TestOutputRejectsInvalidSemanticContext(t *testing.T) {
80+ for _, scenario := range []string{"side effect", "zero publication", "missing lifecycle", "missing tab", "missing pane"} {
81+ t.Run(scenario, func(t *testing.T) {
82+ context := testViewContext()
83+ output := protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 1, Rows: 1}, Context: &context}
84+ switch scenario {
85+ case "side effect":
86+ output.New, output.Full = 0, false
87+ case "zero publication":
88+ context.Publication = 0
89+ case "missing lifecycle":
90+ context.Route.Target.LifecycleID = domain.SessionLifecycleID{}
91+ case "missing tab":
92+ context.TabID = ""
93+ case "missing pane":
94+ context.FocusedPaneID = ""
95+ }
96+ require.ErrorIs(t, protocol.ValidateOutput(output), protocol.ErrInvalidOutput)
97+ _, err := MarshalOutput(output)
98+ require.ErrorIs(t, err, protocol.ErrInvalidOutput)
99+ })
100+ }
101+}
M internal/protocol/wire/remote_preview_hardening_test.go +20 -0
@@ -204,6 +204,8 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
206 0x00, 0x00, 0x00, 0x00,206 0x00, 0x00, 0x00, 0x00,
207+ // CauseActionID.
208+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
207 }209 }
208 if !bytes.Equal(got, want) {210 if !bytes.Equal(got, want) {
209 t.Fatalf("rich target bytes = %x, want %x", got, want)211 t.Fatalf("rich target bytes = %x, want %x", got, want)
@@ -216,3 +218,21 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
216 t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy)218 t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy)
217 }219 }
218 }220 }
221+
222+func TestRemoteTargetWireRejectsMalformedCauseActionID(t *testing.T) {
223+ valid := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
224+ tests := []struct {
225+ name string
226+ payload []byte
227+ }{
228+ {name: "truncated cause action ID", payload: append([]byte(nil), valid[:len(valid)-1]...)},
229+ {name: "trailing bytes", payload: append(append([]byte(nil), valid...), 0)},
230+ }
231+ for _, test := range tests {
232+ t.Run(test.name, func(t *testing.T) {
233+ if _, err := UnmarshalAttachTarget(test.payload); err == nil {
234+ t.Fatal("malformed CauseActionID payload decoded successfully")
235+ }
236+ })
237+ }
238+}
M internal/protocol/wire/remote_target_wire_test.go +4 -2
@@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140 })140 })
141 },141 },
142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
143- policyOffset: 5,143+ policyOffset: 13,
144 wantErr: protocol.ErrInvalidAttachTarget,144 wantErr: protocol.ErrInvalidAttachTarget,
145 },145 },
146 }146 }
@@ -196,7 +196,9 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196 }196 }
197 197
198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {198 func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
199- want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}199+ want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0,
200+ // CauseActionID.
201+ 0, 0, 0, 0, 0, 0, 0, 0}
200 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})202 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201 if !bytes.Equal(got, want) {203 if !bytes.Equal(got, want) {
202 t.Fatalf("target bytes = %x, want %x", got, want)204 t.Fatalf("target bytes = %x, want %x", got, want)
M internal/protocol/wire/routes_test.go +4 -4
@@ -245,7 +245,7 @@ func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) {
245 }245 }
246 246
247 func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {247 func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
248- action := protocol.RouteCreateSessionAction{RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"}248+ action := protocol.RouteCreateSessionAction{CauseActionID: 6, RequestID: 5, SnapshotGeneration: 9, Key: 7, Generation: 8, SessionName: "example"}
249 failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable}249 failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable}
250 tests := []struct {250 tests := []struct {
251 name string251 name string
@@ -255,7 +255,7 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
255 unmarshal func([]byte) (any, error)255 unmarshal func([]byte) (any, error)
256 }{256 }{
257 {257 {
258- name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action,258+ name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c650000000000000006", want: action,
259 marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) },259 marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) },
260 unmarshal: func(data []byte) (any, error) {260 unmarshal: func(data []byte) (any, error) {
261 return UnmarshalRouteCreateSessionAction(data)261 return UnmarshalRouteCreateSessionAction(data)
@@ -297,10 +297,10 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
297 }297 }
298 298
299 func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {299 func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
300- action := protocol.RouteNavigationAction{SnapshotGeneration: 9, Key: 7, Generation: 8}300+ action := protocol.RouteNavigationAction{CauseActionID: 6, SnapshotGeneration: 9, Key: 7, Generation: 8}
301 encoded, err := MarshalRouteNavigationAction(action)301 encoded, err := MarshalRouteNavigationAction(action)
302 require.NoError(t, err)302 require.NoError(t, err)
303- require.Equal(t, "000000000000000900000000000000070000000000000008", hex.EncodeToString(encoded))303+ require.Equal(t, "0000000000000009000000000000000700000000000000080000000000000006", hex.EncodeToString(encoded))
304 assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationAction)304 assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationAction)
305 _, err = UnmarshalRouteNavigationAction(append(append([]byte(nil), encoded...), 0))305 _, err = UnmarshalRouteNavigationAction(append(append([]byte(nil), encoded...), 0))
306 require.Error(t, err)306 require.Error(t, err)
M internal/protocol/wire/routes_wire.go +8 -0
@@ -606,6 +606,7 @@ func MarshalRouteNavigationAction(action protocol.RouteNavigationAction) ([]byte
606 w.putUint64(action.SnapshotGeneration)606 w.putUint64(action.SnapshotGeneration)
607 w.putUint64(action.Key)607 w.putUint64(action.Key)
608 w.putUint64(action.Generation)608 w.putUint64(action.Generation)
609+ w.putUint64(action.CauseActionID)
609 return w.b, nil610 return w.b, nil
610 }611 }
611 612
@@ -622,6 +623,9 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e
622 if action.Generation, err = r.getUint64(); err != nil {623 if action.Generation, err = r.getUint64(); err != nil {
623 return protocol.RouteNavigationAction{}, err624 return protocol.RouteNavigationAction{}, err
624 }625 }
626+ if action.CauseActionID, err = r.getUint64(); err != nil {
627+ return protocol.RouteNavigationAction{}, err
628+ }
625 if err := r.done(); err != nil {629 if err := r.done(); err != nil {
626 return protocol.RouteNavigationAction{}, err630 return protocol.RouteNavigationAction{}, err
627 }631 }
@@ -641,6 +645,7 @@ func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) (
641 w.putUint64(action.Key)645 w.putUint64(action.Key)
642 w.putUint64(action.Generation)646 w.putUint64(action.Generation)
643 w.putString(action.SessionName)647 w.putString(action.SessionName)
648+ w.putUint64(action.CauseActionID)
644 return w.b, nil649 return w.b, nil
645 }650 }
646 651
@@ -663,6 +668,9 @@ func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAct
663 if action.SessionName, err = r.getString(); err != nil {668 if action.SessionName, err = r.getString(); err != nil {
664 return protocol.RouteCreateSessionAction{}, err669 return protocol.RouteCreateSessionAction{}, err
665 }670 }
671+ if action.CauseActionID, err = r.getUint64(); err != nil {
672+ return protocol.RouteCreateSessionAction{}, err
673+ }
666 if err := r.done(); err != nil {674 if err := r.done(); err != nil {
667 return protocol.RouteCreateSessionAction{}, err675 return protocol.RouteCreateSessionAction{}, err
668 }676 }
A internal/protocol/wire/ui_wire.go +148 -0
@@ -0,0 +1,148 @@
1+package wire
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+var errInvalidUIMessage = errors.New("invalid UI protocol message")
11+
12+func marshalViewContext(w *payloadWriter, context protocol.ViewContext) error {
13+ if context.Validate() != nil {
14+ return errInvalidUIMessage
15+ }
16+ w.putUint64(context.Publication)
17+ marshalCommittedRouteIdentityFields(w, context.Route)
18+ w.putString(string(context.TabID))
19+ w.putString(string(context.FocusedPaneID))
20+ return nil
21+}
22+
23+func unmarshalViewContext(r *payloadReader) (protocol.ViewContext, error) {
24+ var context protocol.ViewContext
25+ var err error
26+ if context.Publication, err = r.getUint64(); err != nil {
27+ return protocol.ViewContext{}, err
28+ }
29+ if context.Route.Target, err = unmarshalExactSessionTarget(r); err != nil {
30+ return protocol.ViewContext{}, err
31+ }
32+ if context.Route.Ephemeral, err = r.getBool(); err != nil {
33+ return protocol.ViewContext{}, err
34+ }
35+ tabID, err := r.getString()
36+ if err != nil {
37+ return protocol.ViewContext{}, err
38+ }
39+ paneID, err := r.getString()
40+ if err != nil {
41+ return protocol.ViewContext{}, err
42+ }
43+ context.TabID = domain.TabStableID(tabID)
44+ context.FocusedPaneID = domain.PaneStableID(paneID)
45+ if context.Validate() != nil {
46+ return protocol.ViewContext{}, errInvalidUIMessage
47+ }
48+ return context, nil
49+}
50+
51+func MarshalUIFence(message protocol.UIFence) ([]byte, error) {
52+ if message.ActionID == 0 {
53+ return nil, errInvalidUIMessage
54+ }
55+ w := payloadWriter{}
56+ w.putUint64(message.ActionID)
57+ return w.b, nil
58+}
59+
60+func UnmarshalUIFence(data []byte) (protocol.UIFence, error) {
61+ r := payloadReader{b: data}
62+ actionID, err := r.getUint64()
63+ if err != nil || actionID == 0 {
64+ return protocol.UIFence{}, errInvalidUIMessage
65+ }
66+ if err := r.done(); err != nil {
67+ return protocol.UIFence{}, err
68+ }
69+ return protocol.UIFence{ActionID: actionID}, nil
70+}
71+
72+func MarshalUIReceipt(message protocol.UIReceipt) ([]byte, error) {
73+ if err := message.Validate(); err != nil {
74+ return nil, err
75+ }
76+ w := payloadWriter{}
77+ w.putUint64(message.ActionID)
78+ w.putUint64(message.Epoch)
79+ w.putUint64(message.State)
80+ w.putUint64(message.ViewPublication)
81+ w.putUint8(uint8(message.Outcome))
82+ return w.b, nil
83+}
84+
85+func UnmarshalUIReceipt(data []byte) (protocol.UIReceipt, error) {
86+ r := payloadReader{b: data}
87+ var message protocol.UIReceipt
88+ var err error
89+ if message.ActionID, err = r.getUint64(); err != nil {
90+ return protocol.UIReceipt{}, err
91+ }
92+ if message.Epoch, err = r.getUint64(); err != nil {
93+ return protocol.UIReceipt{}, err
94+ }
95+ if message.State, err = r.getUint64(); err != nil {
96+ return protocol.UIReceipt{}, err
97+ }
98+ if message.ViewPublication, err = r.getUint64(); err != nil {
99+ return protocol.UIReceipt{}, err
100+ }
101+ outcome, err := r.getUint8()
102+ if err != nil {
103+ return protocol.UIReceipt{}, err
104+ }
105+ message.Outcome = protocol.UIReceiptOutcome(outcome)
106+ if err := r.done(); err != nil {
107+ return protocol.UIReceipt{}, err
108+ }
109+ if _, err := MarshalUIReceipt(message); err != nil {
110+ return protocol.UIReceipt{}, err
111+ }
112+ return message, nil
113+}
114+
115+func MarshalUIViewUpdate(message protocol.UIViewUpdate) ([]byte, error) {
116+ if message.Epoch == 0 || message.State == 0 {
117+ return nil, errInvalidUIMessage
118+ }
119+ w := payloadWriter{}
120+ w.putUint64(message.Epoch)
121+ w.putUint64(message.State)
122+ if err := marshalViewContext(&w, message.Context); err != nil {
123+ return nil, err
124+ }
125+ return w.b, nil
126+}
127+
128+func UnmarshalUIViewUpdate(data []byte) (protocol.UIViewUpdate, error) {
129+ r := payloadReader{b: data}
130+ var message protocol.UIViewUpdate
131+ var err error
132+ if message.Epoch, err = r.getUint64(); err != nil {
133+ return protocol.UIViewUpdate{}, err
134+ }
135+ if message.State, err = r.getUint64(); err != nil {
136+ return protocol.UIViewUpdate{}, err
137+ }
138+ if message.Context, err = unmarshalViewContext(&r); err != nil {
139+ return protocol.UIViewUpdate{}, err
140+ }
141+ if err := r.done(); err != nil {
142+ return protocol.UIViewUpdate{}, err
143+ }
144+ if message.Epoch == 0 || message.State == 0 {
145+ return protocol.UIViewUpdate{}, errInvalidUIMessage
146+ }
147+ return message, nil
148+}
A internal/protocol/wire/ui_wire_test.go +107 -0
@@ -0,0 +1,107 @@
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 testViewContext() protocol.ViewContext {
12+ return protocol.ViewContext{
13+ Publication: 1,
14+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
15+ TabID: "tab-1", FocusedPaneID: "pane-1",
16+ }
17+}
18+
19+// testViewContextGolden is independent of the codec; output golden tests also
20+// use its literal length to target compression fields after semantic context.
21+func testViewContextGolden() []byte {
22+ return []byte{
23+ 0, 0, 0, 0, 0, 0, 0, 1,
24+ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
25+ 0, 4, 'w', 'o', 'r', 'k', 0,
26+ 0, 5, 't', 'a', 'b', '-', '1',
27+ 0, 6, 'p', 'a', 'n', 'e', '-', '1',
28+ }
29+}
30+
31+func TestUIWireRoundTripsAndRejectsTruncation(t *testing.T) {
32+ tests := []struct {
33+ name string
34+ marshal func() ([]byte, error)
35+ unmarshal func([]byte) error
36+ mutate func([]byte)
37+ wantMarshalError bool
38+ wantUnmarshalErr bool
39+ }{
40+ {
41+ name: "fence",
42+ marshal: func() ([]byte, error) { return MarshalUIFence(protocol.UIFence{ActionID: 7}) },
43+ unmarshal: func(data []byte) error { _, err := UnmarshalUIFence(data); return err },
44+ },
45+ {
46+ name: "receipt",
47+ marshal: func() ([]byte, error) {
48+ return MarshalUIReceipt(protocol.UIReceipt{ActionID: 7, Epoch: 2, State: 3, ViewPublication: 4, Outcome: protocol.UIReceiptProcessed})
49+ },
50+ unmarshal: func(data []byte) error { _, err := UnmarshalUIReceipt(data); return err },
51+ },
52+ {
53+ name: "unavailable receipt",
54+ marshal: func() ([]byte, error) {
55+ return MarshalUIReceipt(protocol.UIReceipt{ActionID: 7, Outcome: protocol.UIReceiptUnavailable})
56+ },
57+ unmarshal: func(data []byte) error { _, err := UnmarshalUIReceipt(data); return err },
58+ },
59+ {
60+ name: "view update",
61+ marshal: func() ([]byte, error) {
62+ return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 2, State: 3, Context: testViewContext()})
63+ },
64+ unmarshal: func(data []byte) error { _, err := UnmarshalUIViewUpdate(data); return err },
65+ },
66+ {
67+ name: "view update zero state marshal",
68+ marshal: func() ([]byte, error) {
69+ return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 1, Context: testViewContext()})
70+ },
71+ wantMarshalError: true,
72+ },
73+ {
74+ name: "view update zero state wire",
75+ marshal: func() ([]byte, error) {
76+ return MarshalUIViewUpdate(protocol.UIViewUpdate{Epoch: 1, State: 1, Context: testViewContext()})
77+ },
78+ unmarshal: func(data []byte) error { _, err := UnmarshalUIViewUpdate(data); return err },
79+ mutate: func(data []byte) { clear(data[8:16]) },
80+ wantUnmarshalErr: true,
81+ },
82+ }
83+ for _, test := range tests {
84+ t.Run(test.name, func(t *testing.T) {
85+ encoded, err := test.marshal()
86+ if test.wantMarshalError {
87+ require.Error(t, err)
88+ return
89+ }
90+ require.NoError(t, err)
91+ if test.mutate != nil {
92+ test.mutate(encoded)
93+ if test.wantUnmarshalErr {
94+ require.Error(t, test.unmarshal(encoded))
95+ } else {
96+ require.NoError(t, test.unmarshal(encoded))
97+ }
98+ return
99+ }
100+ require.NoError(t, test.unmarshal(encoded))
101+ for size := range len(encoded) {
102+ require.Error(t, test.unmarshal(encoded[:size]), "prefix %d", size)
103+ }
104+ require.Error(t, test.unmarshal(append(append([]byte(nil), encoded...), 0)))
105+ })
106+ }
107+}
M internal/protocol/wire/wire.go +40 -5
@@ -31,7 +31,7 @@ var errInvalidBoolean = errors.New("ports: invalid boolean flag")
31 var errInvalidEnum = errors.New("ports: invalid enum value")31 var errInvalidEnum = errors.New("ports: invalid enum value")
32 32
33 const (33 const (
34- outputHeaderLen = 5*8 + 2*2 + 134+ outputHeaderLen = 5*8 + 2*2 + 2
35 outputCompressionHeaderLen = 1 + 435 outputCompressionHeaderLen = 1 + 4
36 outputPayloadOverhead = outputHeaderLen + outputCompressionHeaderLen + 436 outputPayloadOverhead = outputHeaderLen + outputCompressionHeaderLen + 4
37 // outputCompressionThreshold keeps small snapshots on the canonical path.37 // outputCompressionThreshold keeps small snapshots on the canonical path.
@@ -669,6 +669,7 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) {
669 func MarshalInput(m protocol.Input) []byte {669 func MarshalInput(m protocol.Input) []byte {
670 w := payloadWriter{}670 w := payloadWriter{}
671 w.putUint64(m.InputSeq)671 w.putUint64(m.InputSeq)
672+ w.putUint64(m.ActionID)
672 w.putBytes(m.Data)673 w.putBytes(m.Data)
673 return w.b674 return w.b
674 }675 }
@@ -681,7 +682,11 @@ func UnmarshalInput(b []byte) (protocol.Input, error) {
681 if err != nil {682 if err != nil {
682 return protocol.Input{}, err683 return protocol.Input{}, err
683 }684 }
684- return protocol.Input{InputSeq: seq, Data: r.rest()}, nil685+ actionID, err := r.getUint64()
686+ if err != nil {
687+ return protocol.Input{}, err
688+ }
689+ return protocol.Input{InputSeq: seq, ActionID: actionID, Data: r.rest()}, nil
685 }690 }
686 691
687 // MarshalImagePush encodes m into an ImagePush message payload.692 // MarshalImagePush encodes m into an ImagePush message payload.
@@ -1035,8 +1040,9 @@ func UnmarshalErrorMsg(b []byte) (protocol.ErrorMsg, error) {
1035 }1040 }
1036 1041
1037 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/1042 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
1038-// size/full/compression/decoded-length/data message layout. Compression is1043+// size/full/context-presence/context/compression/decoded-length/data layout.
1039-// limited to large, full snapshots and is retained only when it saves bytes.1044+// State-bearing output requires context; side effects prohibit it. Compression
1045+// is limited to large, full snapshots and retained only when it saves bytes.
1040 func MarshalOutput(m protocol.Output) ([]byte, error) {1046 func MarshalOutput(m protocol.Output) ([]byte, error) {
1041 if err := protocol.ValidateOutput(m); err != nil {1047 if err := protocol.ValidateOutput(m); err != nil {
1042 return nil, err1048 return nil, err
@@ -1054,6 +1060,12 @@ func MarshalOutput(m protocol.Output) ([]byte, error) {
1054 w.putUint16(uint16(m.Size.Cols))1060 w.putUint16(uint16(m.Size.Cols))
1055 w.putUint16(uint16(m.Size.Rows))1061 w.putUint16(uint16(m.Size.Rows))
1056 w.putBool(m.Full)1062 w.putBool(m.Full)
1063+ w.putBool(m.Context != nil)
1064+ if m.Context != nil {
1065+ if err := marshalViewContext(&w, *m.Context); err != nil {
1066+ return nil, err
1067+ }
1068+ }
1057 w.putUint8(kind)1069 w.putUint8(kind)
1058 w.putUint32(uint32(len(m.Data)))1070 w.putUint32(uint32(len(m.Data)))
1059 w.putLongBytes(data)1071 w.putLongBytes(data)
@@ -1122,6 +1134,17 @@ func UnmarshalOutput(b []byte) (protocol.Output, error) {
1122 if m.Full, err = r.getBool(); err != nil {1134 if m.Full, err = r.getBool(); err != nil {
1123 return protocol.Output{}, err1135 return protocol.Output{}, err
1124 }1136 }
1137+ hasContext, err := r.getBool()
1138+ if err != nil {
1139+ return protocol.Output{}, err
1140+ }
1141+ if hasContext {
1142+ context, contextErr := unmarshalViewContext(&r)
1143+ if contextErr != nil {
1144+ return protocol.Output{}, contextErr
1145+ }
1146+ m.Context = &context
1147+ }
1125 if err := protocol.ValidateOutput(m); err != nil {1148 if err := protocol.ValidateOutput(m); err != nil {
1126 return protocol.Output{}, err1149 return protocol.Output{}, err
1127 }1150 }
@@ -1208,6 +1231,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
1208 marshalExactTargetSection(&w, m.ExactTarget)1231 marshalExactTargetSection(&w, m.ExactTarget)
1209 w.putBool(m.SamePeer)1232 w.putBool(m.SamePeer)
1210 w.putString(string(m.PreferredTabID))1233 w.putString(string(m.PreferredTabID))
1234+ w.putUint64(m.CauseActionID)
1211 return w.b1235 return w.b
1212 }1236 }
1213 1237
@@ -1244,6 +1268,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1244 if err := probe.skipString(); err != nil {1268 if err := probe.skipString(); err != nil {
1245 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget1269 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1246 }1270 }
1271+ if _, err := probe.getUint64(); err != nil {
1272+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1273+ }
1247 if err := probe.done(); err != nil {1274 if err := probe.done(); err != nil {
1248 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget1275 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1249 }1276 }
@@ -1276,6 +1303,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
1276 return protocol.AttachTarget{}, err1303 return protocol.AttachTarget{}, err
1277 }1304 }
1278 m.PreferredTabID = domain.TabStableID(preferredTabID)1305 m.PreferredTabID = domain.TabStableID(preferredTabID)
1306+ if m.CauseActionID, err = r.getUint64(); err != nil {
1307+ return protocol.AttachTarget{}, err
1308+ }
1279 if err := r.done(); err != nil {1309 if err := r.done(); err != nil {
1280 return protocol.AttachTarget{}, err1310 return protocol.AttachTarget{}, err
1281 }1311 }
@@ -1724,6 +1754,7 @@ func MarshalNavigationDirective(directive protocol.NavigationDirective) []byte {
1724 w := payloadWriter{}1754 w := payloadWriter{}
1725 w.putUint8(uint8(directive.Action))1755 w.putUint8(uint8(directive.Action))
1726 w.putBytes(directive.LeaseID[:])1756 w.putBytes(directive.LeaseID[:])
1757+ w.putUint64(directive.CauseActionID)
1727 return w.b1758 return w.b
1728 }1759 }
1729 1760
@@ -1738,10 +1769,14 @@ func UnmarshalNavigationDirective(b []byte) (protocol.NavigationDirective, error
1738 if err != nil {1769 if err != nil {
1739 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation1770 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1740 }1771 }
1772+ causeActionID, err := r.getUint64()
1773+ if err != nil {
1774+ return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1775+ }
1741 if err := r.done(); err != nil {1776 if err := r.done(); err != nil {
1742 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation1777 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1743 }1778 }
1744- directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value)}1779+ directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value), CauseActionID: causeActionID}
1745 copy(directive.LeaseID[:], lease)1780 copy(directive.LeaseID[:], lease)
1746 if MarshalNavigationDirective(directive) == nil {1781 if MarshalNavigationDirective(directive) == nil {
1747 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation1782 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
M internal/protocol/wire/wire_final_test.go +20 -12
@@ -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 != 40 {15+ if protocol.Version != 41 {
16- t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)16+ t.Fatalf("ProtocolVersion = %d, want 41", 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,7 +27,7 @@ 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, 40, 2}, make([]byte, 16+8)...)30+ want := append([]byte{0, 41, 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, 0)
33 got := MarshalHello(msg)33 got := MarshalHello(msg)
@@ -105,9 +105,10 @@ func TestFinalResizeGoldenStrict(t *testing.T) {
105 }105 }
106 106
107 func TestFinalOutputGoldenStrict(t *testing.T) {107 func TestFinalOutputGoldenStrict(t *testing.T) {
108+ context := testViewContext()
108 msg := protocol.Output{109 msg := protocol.Output{
109 Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9,110 Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9,
110- Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"),111+ Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Context: &context, Data: []byte("ok"),
111 }112 }
112 want := []byte{113 want := []byte{
113 0, 0, 0, 0, 0, 0, 0, 1,114 0, 0, 0, 0, 0, 0, 0, 1,
@@ -115,10 +116,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
115 0, 0, 0, 0, 0, 0, 0, 7,116 0, 0, 0, 0, 0, 0, 0, 7,
116 0, 0, 0, 0, 0, 0, 0, 8,117 0, 0, 0, 0, 0, 0, 0, 8,
117 0, 0, 0, 0, 0, 0, 0, 9,118 0, 0, 0, 0, 0, 0, 0, 9,
118- 0, 80, 0, 24, 1,119+ 0, 80, 0, 24, 1, 1,
119- 0, 0, 0, 0, 2,
120- 0, 0, 0, 2, 'o', 'k',
121 }120 }
121+ want = append(want, testViewContextGolden()...)
122+ want = append(want, 0, 0, 0, 0, 2, 0, 0, 0, 2, 'o', 'k')
122 got, err := MarshalOutput(msg)123 got, err := MarshalOutput(msg)
123 if err != nil {124 if err != nil {
124 t.Fatalf("MarshalOutput() error = %v", err)125 t.Fatalf("MarshalOutput() error = %v", err)
@@ -135,11 +136,12 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
135 }136 }
136 137
137 func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {138 func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
138- valid := protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}139+ context := testViewContext()
140+ valid := protocol.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Context: &context}
139 for _, bad := range []protocol.Output{141 for _, bad := range []protocol.Output{
140 {},142 {},
141- {Epoch: 1, Base: 0, New: 1, Size: valid.Size},143+ {Epoch: 1, Base: 0, New: 1, Size: valid.Size, Context: &context},
142- {Epoch: 1, Base: 0, New: 1, Full: true},144+ {Epoch: 1, Base: 0, New: 1, Full: true, Context: &context},
143 } {145 } {
144 if got, err := MarshalOutput(bad); err == nil || got != nil {146 if got, err := MarshalOutput(bad); err == nil || got != nil {
145 t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err)147 t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err)
@@ -158,7 +160,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
158 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},160 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
159 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},161 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
160 {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},162 {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
161- {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[46:50], ^uint32(0)) }},163+ {name: "impossible data length", mutate: func(b []byte) {
164+ offset := outputHeaderLen + len(testViewContextGolden()) + 1
165+ binary.BigEndian.PutUint32(b[offset:offset+4], ^uint32(0))
166+ }},
162 } {167 } {
163 t.Run(tt.name, func(t *testing.T) {168 t.Run(tt.name, func(t *testing.T) {
164 bad := append([]byte(nil), payload...)169 bad := append([]byte(nil), payload...)
@@ -197,6 +202,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
197 func TestFinalAttachTargetGoldenStrict(t *testing.T) {202 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}203 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199 want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0}204 want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0}
205+ want = append(want, make([]byte, 8)...)
200 got := MarshalAttachTarget(msg)206 got := MarshalAttachTarget(msg)
201 if !bytes.Equal(got, want) {207 if !bytes.Equal(got, want) {
202 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)208 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -213,6 +219,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
213 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")219 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214 }220 }
215 wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}221 wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0}
222+ wantLocal = append(wantLocal, make([]byte, 8)...)
216 if !bytes.Equal(localPayload, wantLocal) {223 if !bytes.Equal(localPayload, wantLocal) {
217 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)224 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218 }225 }
@@ -249,6 +256,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
249 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)256 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250 want = append(want, make([]byte, 15)...)257 want = append(want, make([]byte, 15)...)
251 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0)258 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0)
259+ want = append(want, make([]byte, 8)...)
252 if !bytes.Equal(payload, want) {260 if !bytes.Equal(payload, want) {
253 t.Fatalf("exact target bytes = %x, want %x", payload, want)261 t.Fatalf("exact target bytes = %x, want %x", payload, want)
254 }262 }
@@ -302,7 +310,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
302 name: "attach target same-peer boolean",310 name: "attach target same-peer boolean",
303 payload: func() []byte {311 payload: func() []byte {
304 b := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})312 b := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
305- b[len(b)-1] = 2313+ b[len(b)-11] = 2
306 return b314 return b
307 }(),315 }(),
308 decode: func(b []byte) error { _, err := UnmarshalAttachTarget(b); return err },316 decode: func(b []byte) error { _, err := UnmarshalAttachTarget(b); return err },
M internal/protocol/wire/wire_test.go +21 -11
@@ -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, 0x28, 0x00, // version, intent, first client ID byte139+ 0x00, 0x29, 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
@@ -217,8 +217,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
217 msg protocol.Input217 msg protocol.Input
218 want []byte218 want []byte
219 }{219 }{
220- {name: "data", msg: protocol.Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},220+ {name: "data", msg: protocol.Input{InputSeq: 0x0102030405060708, ActionID: 9, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0, 0, 0, 0, 0, 0, 0, 9, 0x68, 0x69}},
221- {name: "empty", msg: protocol.Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},221+ {name: "empty", msg: protocol.Input{InputSeq: 0, Data: nil}, want: make([]byte, 16)},
222 }222 }
223 223
224 for _, tt := range tests {224 for _, tt := range tests {
@@ -238,7 +238,7 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
238 }238 }
239 239
240 full := MarshalInput(tests[0].msg)240 full := MarshalInput(tests[0].msg)
241- assertAllPrefixesFail(t, full[:8], UnmarshalInput)241+ assertAllPrefixesFail(t, full[:16], UnmarshalInput)
242 }242 }
243 243
244 func TestImagePushGoldenAndRoundTrip(t *testing.T) {244 func TestImagePushGoldenAndRoundTrip(t *testing.T) {
@@ -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(40), protocol.Version)370+ require.Equal(t, uint16(41), 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, 0x28,531+ 0x00, 0x29,
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, 0x28,554+ 0x00, 0x29,
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
@@ -682,8 +682,18 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
682 }682 }
683 683
684 func TestOutputGoldenAndRoundTrip(t *testing.T) {684 func TestOutputGoldenAndRoundTrip(t *testing.T) {
685- msg := protocol.Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}685+ context := testViewContext()
686- want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}686+ msg := protocol.Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Context: &context, Data: []byte("hello\n")}
687+ want := []byte{
688+ 0, 0, 0, 0, 0, 0, 0, 1,
689+ 0, 0, 0, 0, 0, 0, 0, 0,
690+ 0, 0, 0, 0, 0, 0, 0, 2,
691+ 0, 0, 0, 0, 0, 0, 0, 3,
692+ 0, 0, 0, 0, 0, 0, 0, 0,
693+ 0, 1, 0, 1, 1, 1,
694+ }
695+ want = append(want, testViewContextGolden()...)
696+ want = append(want, 0, 0, 0, 0, 6, 0, 0, 0, 6, 'h', 'e', 'l', 'l', 'o', '\n')
687 697
688 got, err := MarshalOutput(msg)698 got, err := MarshalOutput(msg)
689 if err != nil {699 if err != nil {
@@ -989,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989 name: "attached",999 name: "attached",
990 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"}},
991 want: []byte{1001 want: []byte{
992- 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,1002+ 0x00, 0x29, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
993 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',
994 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',
995 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -999,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
999 name: "control",1009 name: "control",
1000 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},1010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
1001 want: []byte{1011 want: []byte{
1002- 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1012+ 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1003 0x00, 0x02, 'l', 's', 0x00, 0x00,1013 0x00, 0x02, 'l', 's', 0x00, 0x00,
1004 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,1014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1005 },1015 },
M internal/testutil/replaytest/replay.go +5 -0
@@ -13,6 +13,11 @@ import (
13 )13 )
14 14
15 func mustMarshalOutput(m protocol.Output) []byte {15 func mustMarshalOutput(m protocol.Output) []byte {
16+ m.Context = &protocol.ViewContext{
17+ Publication: m.New,
18+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "replay"}},
19+ TabID: "tab-1", FocusedPaneID: "pane-1",
20+ }
16 payload, err := wire.MarshalOutput(m)21 payload, err := wire.MarshalOutput(m)
17 if err != nil {22 if err != nil {
18 panic(err)23 panic(err)
M internal/usecase/client/client.go +349 -81
@@ -161,6 +161,7 @@ type Dependencies struct {
161 // DisableCapabilityProbe skips active outer-terminal discovery for headless161 // DisableCapabilityProbe skips active outer-terminal discovery for headless
162 // embedders and deterministic tests. Interactive clients leave it false.162 // embedders and deterministic tests. Interactive clients leave it false.
163 DisableCapabilityProbe bool163 DisableCapabilityProbe bool
164+ UI *UI
164 Clipboard ports.ClipboardReader165 Clipboard ports.ClipboardReader
165 Logger *slog.Logger166 Logger *slog.Logger
166 RuntimeObserver ports.SerializedRuntimeObserver167 RuntimeObserver ports.SerializedRuntimeObserver
@@ -181,16 +182,20 @@ type Dependencies struct {
181 // client-only presentation metadata. RemoteTarget is an optional exact picker182 // client-only presentation metadata. RemoteTarget is an optional exact picker
182 // handoff and is serialized into Hello only when present.183 // handoff and is serialized into Hello only when present.
183 type AttachRequest struct {184 type AttachRequest struct {
184- Intent uint8185+ Intent uint8
185- SessionName string186+ SessionName string
186- Remote bool187+ Remote bool
187- Origin protocol.RouteOrigin188+ Origin protocol.RouteOrigin
188- OriginKey string189+ OriginKey string
189- RemoteTarget *domain.RemoteSessionTarget190+ RemoteTarget *domain.RemoteSessionTarget
190- ExactTarget *protocol.ExactSessionTarget191+ ExactTarget *protocol.ExactSessionTarget
191- PreferredTabID domain.TabStableID192+ PreferredTabID domain.TabStableID
192- HostLabel string193+ HostLabel string
193- EnvironmentPolicy protocol.EnvironmentPolicy194+ EnvironmentPolicy protocol.EnvironmentPolicy
195+ // Environment, when non-nil, is the complete environment to advertise to
196+ // a client-owned daemon endpoint. It is separate from the driver's process
197+ // environment and is supplied only by explicit composition.
198+ Environment []string
194 NavigationCapabilities protocol.NavigationCapabilities199 NavigationCapabilities protocol.NavigationCapabilities
195 StartupOverlay protocol.StartupOverlay200 StartupOverlay protocol.StartupOverlay
196 }201 }
@@ -217,6 +222,7 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach
217 }222 }
218 223
219 type Runner struct {224 type Runner struct {
225+ ui *UI
220 dialer ports.ClientDialer226 dialer ports.ClientDialer
221 term ports.Terminal227 term ports.Terminal
222 clock ports.Clock228 clock ports.Clock
@@ -246,6 +252,7 @@ func NewRunner(deps Dependencies) *Runner {
246 log = slog.Default()252 log = slog.Default()
247 }253 }
248 return &Runner{254 return &Runner{
255+ ui: deps.UI,
249 dialer: deps.Dialer,256 dialer: deps.Dialer,
250 term: deps.Terminal,257 term: deps.Terminal,
251 clock: deps.Clock,258 clock: deps.Clock,
@@ -369,6 +376,21 @@ func (r *Runner) rememberKittyDirectGraphics(value bool) bool {
369 return value376 return value
370 }377 }
371 378
379+func attachEnvironment(environment []string) []string {
380+ if environment == nil {
381+ return os.Environ()
382+ }
383+ return append([]string(nil), environment...)
384+}
385+
386+func cloneRouteNavigationAction(action *protocol.RouteNavigationAction) *protocol.RouteNavigationAction {
387+ if action == nil {
388+ return nil
389+ }
390+ copyAction := *action
391+ return &copyAction
392+}
393+
372 func validateAttachRequest(request AttachRequest) error {394 func validateAttachRequest(request AttachRequest) error {
373 if request.Origin != 0 {395 if request.Origin != 0 {
374 if err := request.Origin.Validate(); err != nil {396 if err := request.Origin.Validate(); err != nil {
@@ -426,6 +448,15 @@ func validateAttachRequest(request AttachRequest) error {
426 // above attach attempts so raw mode remains active while a live client process448 // above attach attempts so raw mode remains active while a live client process
427 // redials a lost link.449 // redials a lost link.
428 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {450 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
451+ var stopObserve context.CancelFunc
452+ var observed chan struct{}
453+ if r.ui != nil {
454+ observeCtx, cancelObserve := context.WithCancel(ctx)
455+ stopObserve = cancelObserve
456+ observed = make(chan struct{})
457+ go func() { defer close(observed); r.ui.Observe(observeCtx) }()
458+ defer func() { stopObserve(); <-observed }()
459+ }
429 request = cloneAttachRequest(request)460 request = cloneAttachRequest(request)
430 if request.Origin == 0 {461 if request.Origin == 0 {
431 request.Origin = r.origin462 request.Origin = r.origin
@@ -517,6 +548,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
517 }548 }
518 }549 }
519 }()550 }()
551+ if r.ui != nil {
552+ defer r.ui.status(ports.UIStatusDetached)
553+ }
520 remote := request.Remote || r.remote554 remote := request.Remote || r.remote
521 reconnect := &reconnectUI{555 reconnect := &reconnectUI{
522 term: r.term,556 term: r.term,
@@ -632,6 +666,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
632 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)666 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
633 transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)667 transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
634 if err != nil {668 if err != nil {
669+ if r.ui != nil {
670+ r.ui.failHandoff()
671+ }
635 err = handshakeContextError(ctx, timedOut, err)672 err = handshakeContextError(ctx, timedOut, err)
636 finishHandshake()673 finishHandshake()
637 if killedSelection != nil {674 if killedSelection != nil {
@@ -728,6 +765,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
728 request: attemptRequest,765 request: attemptRequest,
729 resumeToken: resumeToken,766 resumeToken: resumeToken,
730 routeNavigationSelection: routeNavigationSelection,767 routeNavigationSelection: routeNavigationSelection,
768+ routeNavigationAction: cloneRouteNavigationAction(routeNavigationAction),
731 killedSelection: killedSelection,769 killedSelection: killedSelection,
732 clientID: processClientID,770 clientID: processClientID,
733 milestones: &ms,771 milestones: &ms,
@@ -744,6 +782,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
744 }).run(ctx)782 }).run(ctx)
745 stopHandshakeTransport()783 stopHandshakeTransport()
746 finishHandshake()784 finishHandshake()
785+ if result.err != nil && r.ui != nil {
786+ r.ui.failHandoff()
787+ }
747 if result.welcomed {788 if result.welcomed {
748 backoff = defaultReconnectBackoff.initial789 backoff = defaultReconnectBackoff.initial
749 }790 }
@@ -1264,6 +1305,7 @@ type attachAttempt struct {
1264 request AttachRequest1305 request AttachRequest
1265 resumeToken uint641306 resumeToken uint64
1266 routeNavigationSelection *routeNavigationSelection1307 routeNavigationSelection *routeNavigationSelection
1308+ routeNavigationAction *protocol.RouteNavigationAction
1267 killedSelection *killedRouteSelection1309 killedSelection *killedRouteSelection
1268 clientID [16]byte1310 clientID [16]byte
1269 milestones *milestones1311 milestones *milestones
@@ -1433,7 +1475,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1433 TrueColor: trueColor,1475 TrueColor: trueColor,
1434 KittyDirectGraphics: kittyDirectGraphics,1476 KittyDirectGraphics: kittyDirectGraphics,
1435 MaxOutputInFlight: requestedOutputWindow(transport),1477 MaxOutputInFlight: requestedOutputWindow(transport),
1436- Env: os.Environ(),1478+ Env: attachEnvironment(request.Environment),
1437 RemoteTarget: request.RemoteTarget,1479 RemoteTarget: request.RemoteTarget,
1438 ExactTarget: exactTarget,1480 ExactTarget: exactTarget,
1439 PreferredTabID: request.PreferredTabID,1481 PreferredTabID: request.PreferredTabID,
@@ -1569,6 +1611,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1569 controlCh := make(chan protocol.ClientMessage, 1)1611 controlCh := make(chan protocol.ClientMessage, 1)
1570 barrierCh := make(chan chan struct{})1612 barrierCh := make(chan chan struct{})
1571 inputGate := newSamePeerInputGate()1613 inputGate := newSamePeerInputGate()
1614+ inputGate.ui = a.runner.ui
1572 ackQueue := newCumulativeAckQueue()1615 ackQueue := newCumulativeAckQueue()
1573 sendErrCh := make(chan error, 1)1616 sendErrCh := make(chan error, 1)
1574 senderStarted := false1617 senderStarted := false
@@ -1678,6 +1721,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1678 }1721 }
1679 awaitingReconnectReset := false1722 awaitingReconnectReset := false
1680 outputState := outputApplyState{}1723 outputState := outputApplyState{}
1724+ uiOutput, _ := term.(ports.UIOutputTransaction)
1725+ uiContext := ports.UIContext{Generation: 1}
1726+ ui := a.runner.ui
1727+ publishUIStatus := func(status ports.UIPresentationStatus) {
1728+ uiContext.Status = status
1729+ if ui != nil {
1730+ ui.status(status)
1731+ }
1732+ }
1733+ var uiGeneration uint64
1681 outputResetRequested := false1734 outputResetRequested := false
1682 transitionWaitingFull := false1735 transitionWaitingFull := false
1683 var samePeerSwitch *samePeerSwitchPending1736 var samePeerSwitch *samePeerSwitchPending
@@ -1737,6 +1790,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1737 }1790 }
1738 foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)1791 foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
1739 consumer := input.claim()1792 consumer := input.claim()
1793+ if ui != nil {
1794+ uiGeneration = ui.bindForeground(foregroundCtx, input, consumer)
1795+ uiContext.AttachmentHandle = ui.Handle()
1796+ uiContext.Generation = uiGeneration
1797+ publishUIStatus(ports.UIStatusTransitioning)
1798+ }
1799+ generation := uiGeneration
1740 stdinDone := make(chan struct{})1800 stdinDone := make(chan struct{})
1741 resizeDone := make(chan struct{})1801 resizeDone := make(chan struct{})
1742 sendLease := newForegroundSendLease()1802 sendLease := newForegroundSendLease()
@@ -1747,7 +1807,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1747 foregroundCancel()1807 foregroundCancel()
1748 cancel()1808 cancel()
1749 }1809 }
1750- (&stdinPump{ctx: foregroundCtx, cancel: cancelAll, input: input, consumer: consumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, sendLease: sendLease}).run()1810+ (&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()
1751 }()1811 }()
1752 go func() {1812 go func() {
1753 defer close(resizeDone)1813 defer close(resizeDone)
@@ -2037,6 +2097,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2037 }2097 }
2038 continue2098 continue
2039 }2099 }
2100+ if ui != nil {
2101+ publishUIStatus(ports.UIStatusReconnecting)
2102+ }
2040 stage := stageForLinkState(ev.State)2103 stage := stageForLinkState(ev.State)
2041 if reconnect.remote && reconnect.showing && reconnect.stage != stage {2104 if reconnect.remote && reconnect.showing && reconnect.stage != stage {
2042 reconnect.stage = stage2105 reconnect.stage = stage
@@ -2056,6 +2119,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2056 return welcomedResult(errLinkOffline)2119 return welcomedResult(errLinkOffline)
2057 }2120 }
2058 case event := <-paletteEvents:2121 case event := <-paletteEvents:
2122+ if event.kind == paletteEventUIBatchEnd {
2123+ close(event.batchApplied)
2124+ continue
2125+ }
2059 if event.kind == paletteEventScheme {2126 if event.kind == paletteEventScheme {
2060 retained := themeState.update(func(current *protocol.Theme) {2127 retained := themeState.update(func(current *protocol.Theme) {
2061 current.SchemeKnown = true2128 current.SchemeKnown = true
@@ -2063,12 +2130,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2063 })2130 })
2064 actions := coordinator.start(retained, true)2131 actions := coordinator.start(retained, true)
2065 activeGeneration.Store(uint64(coordinator.current.id))2132 activeGeneration.Store(uint64(coordinator.current.id))
2066- if err := processPaletteActions(actions); err != nil {2133+ err := processPaletteActions(actions)
2134+ event.applied()
2135+ if err != nil {
2067 return welcomedResult(err)2136 return welcomedResult(err)
2068 }2137 }
2069 continue2138 continue
2070 }2139 }
2071- if err := processPaletteActions(coordinator.handle(event)); err != nil {2140+ err := processPaletteActions(coordinator.handle(event))
2141+ event.applied()
2142+ if err != nil {
2072 return welcomedResult(err)2143 return welcomedResult(err)
2073 }2144 }
2074 case r := <-recvCh:2145 case r := <-recvCh:
@@ -2099,7 +2170,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2099 if parkedWaitingFull && !o.Full {2170 if parkedWaitingFull && !o.Full {
2100 return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))2171 return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
2101 }2172 }
2173+ if ui != nil && o.Full && foreground == nil && (parkedWaitingFull || samePeerSwitch == nil) {
2174+ startForeground()
2175+ }
2176+ if uiOutput != nil {
2177+ status := ports.UIStatusAttached
2178+ if reconnect.showing {
2179+ status = ports.UIStatusReconnecting
2180+ } else if !o.Full && transition != nil && transition.active {
2181+ status = ports.UIStatusTransitioning
2182+ }
2183+ uiContext = nextState.uiContext(uiContext, status)
2184+ uiOutput.BeginOutput(uiContext)
2185+ }
2102 if _, werr := term.Out().Write(o.Data); werr != nil {2186 if _, werr := term.Out().Write(o.Data); werr != nil {
2187+ if uiOutput != nil {
2188+ uiOutput.EndOutput(false)
2189+ }
2103 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))2190 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
2104 }2191 }
2105 if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {2192 if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
@@ -2108,15 +2195,33 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2108 if reconnect.showing && reconnect.remote {2195 if reconnect.showing && reconnect.remote {
2109 geometry, serr := term.Geometry()2196 geometry, serr := term.Geometry()
2110 if serr != nil {2197 if serr != nil {
2198+ if uiOutput != nil {
2199+ uiOutput.EndOutput(false)
2200+ }
2111 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))2201 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
2112 }2202 }
2113 if rerr := reconnect.redraw(geometry.Size); rerr != nil {2203 if rerr := reconnect.redraw(geometry.Size); rerr != nil {
2204+ if uiOutput != nil {
2205+ uiOutput.EndOutput(false)
2206+ }
2114 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))2207 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
2115 }2208 }
2116 } else if ferr := term.Flush(); ferr != nil {2209 } else if ferr := term.Flush(); ferr != nil {
2210+ if uiOutput != nil {
2211+ uiOutput.EndOutput(false)
2212+ }
2117 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))2213 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
2118 }2214 }
2215+ if uiOutput != nil {
2216+ uiOutput.EndOutput(true)
2217+ }
2119 outputState = nextState2218 outputState = nextState
2219+ if ui != nil && o.New != 0 {
2220+ ui.published(uiGeneration)
2221+ if o.Full {
2222+ ui.destinationFull(uiGeneration)
2223+ }
2224+ }
2120 if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {2225 if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
2121 transitionWaitingFull = false2226 transitionWaitingFull = false
2122 transition.stop()2227 transition.stop()
@@ -2138,7 +2243,38 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2138 log.Debug("received first output")2243 log.Debug("received first output")
2139 endHandshake()2244 endHandshake()
2140 }2245 }
2246+ case protocol.UIViewUpdate:
2247+ nextState, accepted, needsReset := outputState.nextView(message)
2248+ if needsReset && !outputResetRequested {
2249+ select {
2250+ case sendCh <- protocol.OutputResetRequest{}:
2251+ outputResetRequested = true
2252+ case <-loopCtx.Done():
2253+ return loopCanceledResult()
2254+ }
2255+ }
2256+ if !accepted {
2257+ continue
2258+ }
2259+ if uiOutput != nil {
2260+ uiContext = nextState.uiContext(uiContext, uiContext.Status)
2261+ if err := uiOutput.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
2262+ return welcomedResult(fmt.Errorf("vev: publishing UI context: %w", err))
2263+ }
2264+ }
2265+ outputState = nextState
2266+ if ui != nil {
2267+ ui.published(uiGeneration)
2268+ }
2269+ case protocol.UIReceipt:
2270+ if ui != nil {
2271+ ui.receipt(uiGeneration, message)
2272+ }
2141 case protocol.AttachTarget:2273 case protocol.AttachTarget:
2274+ if ui != nil {
2275+ ui.follow(uiGeneration, message.CauseActionID)
2276+ publishUIStatus(ports.UIStatusTransitioning)
2277+ }
2142 target := message2278 target := message
2143 if transientPicker {2279 if transientPicker {
2144 handoff := welcomedResult(nil)2280 handoff := welcomedResult(nil)
@@ -2164,6 +2300,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2164 return welcomedResult(errors.New("vev: invalid same-peer switch"))2300 return welcomedResult(errors.New("vev: invalid same-peer switch"))
2165 }2301 }
2166 samePeerSwitch = &samePeerSwitchPending{requestID: nextSamePeerRequestID, target: *target.ExactTarget}2302 samePeerSwitch = &samePeerSwitchPending{requestID: nextSamePeerRequestID, target: *target.ExactTarget}
2303+ if ui != nil {
2304+ stopForeground()
2305+ ui.mu.Lock()
2306+ inputGate.retiredUI.Store(ui.nextAction)
2307+ ui.mu.Unlock()
2308+ }
2167 inputGate.setPaused(true)2309 inputGate.setPaused(true)
2168 select {2310 select {
2169 case controlCh <- switchRequest:2311 case controlCh <- switchRequest:
@@ -2219,6 +2361,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2219 }2361 }
2220 }2362 }
2221 case protocol.NavigationDirective:2363 case protocol.NavigationDirective:
2364+ if ui != nil {
2365+ ui.follow(uiGeneration, message.CauseActionID)
2366+ publishUIStatus(ports.UIStatusTransitioning)
2367+ }
2222 directive := message2368 directive := message
2223 datagramRoute := transport.Capabilities().PreferredOutputWindow == 12369 datagramRoute := transport.Capabilities().PreferredOutputWindow == 1
2224 if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {2370 if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
@@ -2251,6 +2397,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2251 }2397 }
2252 continue2398 continue
2253 }2399 }
2400+ if ui != nil {
2401+ ui.follow(uiGeneration, action.CauseActionID)
2402+ publishUIStatus(ports.UIStatusTransitioning)
2403+ }
2254 result := welcomedResult(nil)2404 result := welcomedResult(nil)
2255 result.routeCreateAction = &action2405 result.routeCreateAction = &action
2256 return result2406 return result
@@ -2272,6 +2422,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2272 if noOp {2422 if noOp {
2273 continue2423 continue
2274 }2424 }
2425+ if ui != nil {
2426+ ui.follow(uiGeneration, action.CauseActionID)
2427+ publishUIStatus(ports.UIStatusTransitioning)
2428+ }
2275 navigation := welcomedResult(nil)2429 navigation := welcomedResult(nil)
2276 navigation.routeAction = &action2430 navigation.routeAction = &action
2277 return navigation2431 return navigation
@@ -2301,9 +2455,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2301 case protocol.SamePeerSwitchFailure:2455 case protocol.SamePeerSwitchFailure:
2302 failure := message2456 failure := message
2303 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {2457 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
2458+ if ui != nil {
2459+ ui.failHandoff()
2460+ }
2304 samePeerSwitch = nil2461 samePeerSwitch = nil
2305 inputGate.setPaused(false)2462 inputGate.setPaused(false)
2306- if transition != nil && transition.showing {2463+ if ui != nil || transition != nil && transition.showing {
2307 transitionWaitingFull = true2464 transitionWaitingFull = true
2308 if !outputResetRequested {2465 if !outputResetRequested {
2309 select {2466 select {
@@ -2329,6 +2486,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
2329 case protocol.RouteNavigationFailure:2486 case protocol.RouteNavigationFailure:
2330 failure := message2487 failure := message
2331 log.Warn("route navigation rejected", "key", failure.Key, "generation", failure.Generation, "code", failure.Code)2488 log.Warn("route navigation rejected", "key", failure.Key, "generation", failure.Generation, "code", failure.Code)
2489+ if a.routeNavigationAction != nil && failure.Key == a.routeNavigationAction.Key && failure.Generation == a.routeNavigationAction.Generation && a.runner.ui != nil {
2490+ a.runner.ui.failHandoff()
2491+ }
2332 case protocol.Detached:2492 case protocol.Detached:
2333 d := message2493 d := message
2334 ms.detached = true2494 ms.detached = true
@@ -2427,6 +2587,9 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
2427 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.ClientConnection, control <-chan protocol.ClientMessage, barriers <-chan chan struct{}, in <-chan protocol.ClientMessage, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {2587 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.ClientConnection, control <-chan protocol.ClientMessage, barriers <-chan chan struct{}, in <-chan protocol.ClientMessage, inputGate *samePeerInputGate, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
2428 defer log.Debug("sender pump exited")2588 defer log.Debug("sender pump exited")
2429 send := func(message protocol.ClientMessage) bool {2589 send := func(message protocol.ClientMessage) bool {
2590+ if inputGate.discardRetiredUI(message) {
2591+ return true
2592+ }
2430 if err := transport.SendClient(message); err != nil {2593 if err := transport.SendClient(message); err != nil {
2431 select {2594 select {
2432 case errCh <- err:2595 case errCh <- err:
@@ -2562,9 +2725,32 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.C
2562 2725
2563 // terminalReadResult is copied by terminalInputPump before the next Read so2726 // terminalReadResult is copied by terminalInputPump before the next Read so
2564 // terminal input remains owned by one lifecycle reader across reconnects.2727 // terminal input remains owned by one lifecycle reader across reconnects.
2728+type terminalInputSource uint8
2729+
2730+const (
2731+ terminalInputHuman terminalInputSource = iota
2732+ terminalInputReply
2733+ terminalInputAutomation
2734+)
2735+
2565 type terminalReadResult struct {2736 type terminalReadResult struct {
2566- data []byte2737+ data []byte
2567- err error2738+ err error
2739+ source terminalInputSource
2740+ generation uint64
2741+ actionID uint64
2742+ endBatch bool
2743+}
2744+
2745+// terminalAutomationRequest is admitted only by the foreground scanner owner.
2746+// Its reply is buffered so cancellation never holds up physical input.
2747+type terminalAutomationRequest struct {
2748+ ctx context.Context
2749+ owner *UI
2750+ consumer uint64
2751+ record terminalReadResult
2752+ admitted chan bool
2753+ dispatched chan bool
2568 }2754 }
2569 2755
2570 // terminalInputPump is the sole consumer of the caller-owned terminal reader.2756 // terminalInputPump is the sole consumer of the caller-owned terminal reader.
@@ -2573,8 +2759,9 @@ type terminalReadResult struct {
2573 // after Run exits; suspend drops any result received while inactive and never2759 // after Run exits; suspend drops any result received while inactive and never
2574 // closes caller-owned stdin. Reconnects and later runs never add another reader.2760 // closes caller-owned stdin. Reconnects and later runs never add another reader.
2575 type terminalInputPump struct {2761 type terminalInputPump struct {
2576- in io.Reader2762+ in io.Reader
2577- done chan struct{}2763+ done chan struct{}
2764+ automation chan terminalAutomationRequest
2578 2765
2579 mu sync.Mutex2766 mu sync.Mutex
2580 // residual holds scanner bytes that were read but not delivered when an2767 // residual holds scanner bytes that were read but not delivered when an
@@ -2605,6 +2792,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
2605 return &terminalInputPump{2792 return &terminalInputPump{
2606 in: in,2793 in: in,
2607 done: make(chan struct{}),2794 done: make(chan struct{}),
2795+ automation: make(chan terminalAutomationRequest),
2608 activation: 1,2796 activation: 1,
2609 active: true,2797 active: true,
2610 ready: make(chan struct{}, 1),2798 ready: make(chan struct{}, 1),
@@ -2913,6 +3101,7 @@ type stdinPump struct {
2913 in io.Reader // compatibility for isolated scanner tests3101 in io.Reader // compatibility for isolated scanner tests
2914 input *terminalInputPump3102 input *terminalInputPump
2915 consumer uint643103 consumer uint64
3104+ uiGeneration uint64
2916 out chan<- protocol.ClientMessage3105 out chan<- protocol.ClientMessage
2917 clock ports.Clock3106 clock ports.Clock
2918 clipboard ports.ClipboardReader3107 clipboard ports.ClipboardReader
@@ -2930,6 +3119,16 @@ func (p *stdinPump) run() {
2930 var scanner theme.Scanner3119 var scanner theme.Scanner
2931 var markers paletteMarkerScanner3120 var markers paletteMarkerScanner
2932 var inputSeq uint643121 var inputSeq uint64
3122+ var actionID atomic.Uint64
3123+ var automated bool
3124+ var activeBatch *terminalAutomationRequest
3125+ var batchSent atomic.Bool
3126+ var batchComplete bool
3127+ defer func() {
3128+ if activeBatch != nil {
3129+ activeBatch.dispatched <- batchComplete
3130+ }
3131+ }()
2933 var sendOK atomic.Bool3132 var sendOK atomic.Bool
2934 var undeliveredMu sync.Mutex3133 var undeliveredMu sync.Mutex
2935 var undelivered []byte3134 var undelivered []byte
@@ -2951,11 +3150,16 @@ func (p *stdinPump) run() {
2951 }3150 }
2952 })3151 })
2953 }3152 }
3153+ var pendingEvents atomic.Int64
2954 sendEvent := func(event paletteGenerationEvent) bool {3154 sendEvent := func(event paletteGenerationEvent) bool {
2955 if !sendOK.Load() {3155 if !sendOK.Load() {
2956 return false3156 return false
2957 }3157 }
2958- return p.sendLease.send(func() bool {3158+ if p.uiGeneration != 0 && event.kind != paletteEventUIBatchEnd {
3159+ event.pending = &pendingEvents
3160+ pendingEvents.Add(1)
3161+ }
3162+ ok := p.sendLease.send(func() bool {
2959 select {3163 select {
2960 case p.paletteEvents <- event:3164 case p.paletteEvents <- event:
2961 if p.afterPaletteEvent != nil {3165 if p.afterPaletteEvent != nil {
@@ -2967,13 +3171,20 @@ func (p *stdinPump) run() {
2967 return false3171 return false
2968 }3172 }
2969 })3173 })
3174+ if !ok {
3175+ event.applied()
3176+ }
3177+ return ok
2970 }3178 }
2971 coalescer := newPasteCoalescer(p.clock, func(data []byte) {3179 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
2972 if len(data) == 0 {3180 if len(data) == 0 {
2973 return3181 return
2974 }3182 }
2975 inputSeq++3183 inputSeq++
2976- if !send(protocol.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)}) {3184+ if actionID.Load() != 0 {
3185+ batchSent.Store(true)
3186+ }
3187+ if !send(protocol.Input{InputSeq: inputSeq, ActionID: actionID.Load(), Data: append([]byte(nil), data...)}) {
2977 undeliveredMu.Lock()3188 undeliveredMu.Lock()
2978 undelivered = append(undelivered, data...)3189 undelivered = append(undelivered, data...)
2979 undeliveredMu.Unlock()3190 undeliveredMu.Unlock()
@@ -3014,7 +3225,7 @@ func (p *stdinPump) run() {
3014 // that undecided suffix; the rest of its source read has already been3225 // that undecided suffix; the rest of its source read has already been
3015 // delivered and must not be replayed by the next attempt.3226 // delivered and must not be replayed by the next attempt.
3016 defer func() {3227 defer func() {
3017- if p.ctx.Err() != nil {3228+ if p.ctx.Err() != nil && !automated {
3018 // Closing flushes any bracketed-paste prefix or buffer through the3229 // Closing flushes any bracketed-paste prefix or buffer through the
3019 // coalescer's emit callback. Failed ordinary-input deliveries are3230 // coalescer's emit callback. Failed ordinary-input deliveries are
3020 // retained ahead of a trailing undecided marker prefix, matching their3231 // retained ahead of a trailing undecided marker prefix, matching their
@@ -3067,86 +3278,143 @@ func (p *stdinPump) run() {
3067 defer disarmMarkerDeadline()3278 defer disarmMarkerDeadline()
3068 3279
3069 for {3280 for {
3281+ var result terminalReadResult
3282+ var batch *terminalAutomationRequest
3070 select {3283 select {
3071 case <-p.ctx.Done():3284 case <-p.ctx.Done():
3072 return3285 return
3073 case <-markerTimerC:3286 case <-markerTimerC:
3074 flushMarkerPrefix()3287 flushMarkerPrefix()
3288+ continue
3289+ case request := <-input.automation:
3290+ input.mu.Lock()
3291+ clean := input.consumer == consumer && request.consumer == consumer && input.pending == nil && len(input.residual) == 0 && input.delivering == 0
3292+ input.mu.Unlock()
3293+ clean = clean && request.ctx.Err() == nil && p.ctx.Err() == nil && !scanner.Pending() && !markers.hasPendingPrefix() && coalescer.Idle() && pendingEvents.Load() == 0
3294+ clean = clean && request.record.source == terminalInputAutomation && request.record.actionID != 0 && request.record.generation != 0 && request.record.generation == p.uiGeneration && request.record.endBatch && len(request.record.data) > 0 && len(request.record.data) <= uiMaxInputBytes && validUIKeyBatch(request.record.data)
3295+ if clean && request.owner != nil {
3296+ clean = request.owner.accept(request.record.actionID, request.record.generation)
3297+ }
3298+ request.admitted <- clean
3299+ if !clean {
3300+ continue
3301+ }
3302+ batch = &request
3303+ activeBatch = batch
3304+ batchSent.Store(false)
3305+ batchComplete = false
3306+ result = request.record
3307+ automated = true
3308+ actionID.Store(result.actionID)
3075 case <-input.ready:3309 case <-input.ready:
3076- result, ok := input.take(p.ctx, consumer)3310+ var ok bool
3311+ result, ok = input.take(p.ctx, consumer)
3077 if !ok {3312 if !ok {
3078 if p.ctx.Err() != nil {3313 if p.ctx.Err() != nil {
3079 return3314 return
3080 }3315 }
3081 continue3316 continue
3082 }3317 }
3083- if p.afterInputTake != nil {3318+ }
3084- p.afterInputTake()3319+ if p.afterInputTake != nil {
3085- }3320+ p.afterInputTake()
3086- // The lease is not acknowledged until scanner delivery completes.3321+ }
3087- // This second check closes the cancellation window after take.3322+ // The lease is not acknowledged until scanner delivery completes.
3088- if p.ctx.Err() != nil {3323+ // This second check closes the cancellation window after take.
3089- return3324+ if p.ctx.Err() != nil {
3090- }3325+ return
3091- disarmMarkerDeadline()3326+ }
3092- // All scanner callbacks from this Read must retain this one generation.3327+ disarmMarkerDeadline()
3093- // A scheme notification can start its replacement before a later byte in3328+ // All scanner callbacks from this Read must retain this one generation.
3094- // the same read is scanned; reloading there would misclassify an old3329+ // A scheme notification can start its replacement before a later byte in
3095- // completion marker as the replacement's drain response.3330+ // the same read is scanned; reloading there would misclassify an old
3096- readGeneration := paletteGenerationID(p.activeGeneration.Load())3331+ // completion marker as the replacement's drain response.
3097- if len(result.data) > 0 {3332+ readGeneration := paletteGenerationID(p.activeGeneration.Load())
3098- scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {3333+ if len(result.data) > 0 {
3099- kindEvent := paletteEventForeground3334+ scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {
3100- if kind == 11 {3335+ kindEvent := paletteEventForeground
3101- kindEvent = paletteEventBackground3336+ if kind == 11 {
3102- }3337+ kindEvent = paletteEventBackground
3103- sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})3338+ }
3104- }, func(slot int, rgb renderer.RGB) {3339+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
3105- // Scanner implementations are independently testable; retain the3340+ }, func(slot int, rgb renderer.RGB) {
3106- // protocol boundary here too so a malformed implementation cannot3341+ // Scanner implementations are independently testable; retain the
3107- // wrap a negative or oversized int into an ANSI palette slot.3342+ // protocol boundary here too so a malformed implementation cannot
3108- slot8, ok := paletteSlot(slot)3343+ // wrap a negative or oversized int into an ANSI palette slot.
3109- if !ok {3344+ slot8, ok := paletteSlot(slot)
3110- return3345+ if !ok {
3111- }
3112- sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
3113- }, func(light bool) {
3114- sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
3115- }, func(data []byte) {
3116- markers.scan(data, sink, func() {
3117- sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
3118- })
3119- })
3120- if !sendOK.Load() {
3121- // Commit the source read so callbacks accepted before cancellation
3122- // are never replayed. The deferred handoff retains only ordinary
3123- // bytes whose callback could not be delivered, plus any undecided
3124- // trailing marker prefix.
3125- input.ack(consumer)
3126 return3346 return
3127 }3347 }
3348+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
3349+ }, func(light bool) {
3350+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
3351+ }, func(data []byte) {
3352+ markers.scan(data, sink, func() {
3353+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
3354+ })
3355+ })
3356+ if !sendOK.Load() {
3357+ // Commit the source read so callbacks accepted before cancellation
3358+ // are never replayed. The deferred handoff retains only ordinary
3359+ // bytes whose callback could not be delivered, plus any undecided
3360+ // trailing marker prefix.
3361+ input.ack(consumer)
3362+ return
3128 }3363 }
3129- if markers.hasPendingPrefix() {3364+ }
3130- armMarkerDeadline()3365+ if batch != nil {
3131- }3366+ scanner.EndBatch(func(data []byte) {
3132- if result.err != nil {3367+ markers.scan(data, sink, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) })
3133- disarmMarkerDeadline()3368+ })
3134- markers.flush(sink)3369+ markers.flush(sink)
3370+ ok := coalescer.EndBatch() && sendOK.Load()
3371+ batchComplete = ok && pendingEvents.Load() == 0
3372+ applied := make(chan struct{})
3373+ if ok {
3374+ ok = sendEvent(paletteGenerationEvent{kind: paletteEventUIBatchEnd, batchApplied: applied})
3375+ }
3376+ if ok {
3135 select {3377 select {
3136- case p.out <- protocol.Detach{}:3378+ case <-applied:
3379+ batchComplete = true
3137 case <-p.ctx.Done():3380 case <-p.ctx.Done():
3381+ ok = false
3138 }3382 }
3139- p.cancel()
3140- return
3141 }3383 }
3142- // Commit every callback from this raw read before observing a3384+ if ok && batchSent.Load() {
3143- // concurrent cancellation. Any remaining marker prefix is retained3385+ ok = send(protocol.UIFence{ActionID: result.actionID})
3144- // by the deferred handoff above, so reconnects never duplicate the3386+ } else if ok && batch.owner != nil {
3145- // delivered bytes or lose a standalone Escape.3387+ batch.owner.completeLocal(result.generation, result.actionID)
3146- input.ack(consumer)3388+ }
3147- if p.ctx.Err() != nil {3389+ batch.dispatched <- batchComplete
3390+ activeBatch = nil
3391+ if !ok {
3148 return3392 return
3149 }3393 }
3394+ actionID.Store(0)
3395+ automated = false
3396+ continue
3397+ }
3398+ if markers.hasPendingPrefix() {
3399+ armMarkerDeadline()
3400+ }
3401+ if result.err != nil {
3402+ disarmMarkerDeadline()
3403+ markers.flush(sink)
3404+ select {
3405+ case p.out <- protocol.Detach{}:
3406+ case <-p.ctx.Done():
3407+ }
3408+ p.cancel()
3409+ return
3410+ }
3411+ // Commit every callback from this raw read before observing a
3412+ // concurrent cancellation. Any remaining marker prefix is retained
3413+ // by the deferred handoff above, so reconnects never duplicate the
3414+ // delivered bytes or lose a standalone Escape.
3415+ input.ack(consumer)
3416+ if p.ctx.Err() != nil {
3417+ return
3150 }3418 }
3151 }3419 }
3152 }3420 }
M internal/usecase/client/output_state.go +52 -21
@@ -1,52 +1,83 @@
1 package client1 package client
2 2
3-import "github.com/bnema/vev/internal/protocol"3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ "github.com/bnema/vev/internal/protocol"
6+)
4 7
5 // outputApplyState is the client-side dependency chain for one attachment.8 // outputApplyState is the client-side dependency chain for one attachment.
6-// State-bearing frames are accepted only when their epoch, base, and view9+// Cells and captured semantic context are admitted together and committed only
7-// revision continue the last frame; a new epoch must carry a full reset.10+// after the corresponding terminal transaction succeeds.
8 type outputApplyState struct {11 type outputApplyState struct {
9 epoch uint6412 epoch uint64
10 state uint6413 state uint64
11 viewRevision uint6414 viewRevision uint64
12 initialized bool15 initialized bool
16+ context protocol.ViewContext
13 }17 }
14 18
15 func (s outputApplyState) next(output protocol.Output) (outputApplyState, bool) {19 func (s outputApplyState) next(output protocol.Output) (outputApplyState, bool) {
16 if output.Epoch == 0 {20 if output.Epoch == 0 {
17 return outputApplyState{}, false21 return outputApplyState{}, false
18 }22 }
19- // State-independent terminal side effects are ordered on the same transport23+ // Ordered side effects cross replay reset gates, but cannot advance or
20- // as ordinary output but deliberately do not advance or acknowledge the24+ // overwrite committed state. Handoff cleanup must not invent a destination.
21- // replay chain. They must still reach the terminal while a reset request is
22- // gating state-bearing frames; handoff cleanup is the final such frame on
23- // the old attachment and no later replay can make it up.
24 if output.New == 0 {25 if output.New == 0 {
25- if output.Base != 0 || output.Full {26+ if output.Base != 0 || output.Full || output.Context != nil {
26 return outputApplyState{}, false27 return outputApplyState{}, false
27 }28 }
28 return s, true29 return s, true
29 }30 }
31+ if output.Context == nil || output.Context.Validate() != nil || output.Context.Publication <= s.context.Publication {
32+ return outputApplyState{}, false
33+ }
30 if !s.initialized {34 if !s.initialized {
31 if output.Base != 0 || !output.Full {35 if output.Base != 0 || !output.Full {
32 return outputApplyState{}, false36 return outputApplyState{}, false
33 }37 }
34- return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true38+ } else {
35- }39+ if output.Epoch < s.epoch {
36- if output.Epoch < s.epoch {
37- return outputApplyState{}, false
38- }
39- if output.Epoch == s.epoch {
40- if output.ViewRevision != s.viewRevision {
41 return outputApplyState{}, false40 return outputApplyState{}, false
42 }41 }
43- if output.Full || output.Base != s.state || output.New != output.Base+1 {42+ if output.Epoch == s.epoch {
43+ if output.ViewRevision != s.viewRevision || output.Context.Route.Target.LifecycleID != s.context.Route.Target.LifecycleID {
44+ return outputApplyState{}, false
45+ }
46+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
47+ return outputApplyState{}, false
48+ }
49+ } else if !output.Full || output.Base != 0 {
44 return outputApplyState{}, false50 return outputApplyState{}, false
45 }51 }
46- return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
47 }52 }
48- if !output.Full || output.Base != 0 {53+ return outputApplyState{
49- return outputApplyState{}, false54+ epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision,
55+ initialized: true, context: *output.Context,
56+ }, true
57+}
58+
59+// nextView returns accepted and reset-needed separately: old updates are
60+// harmless, while missing/future dependencies use the existing coalesced reset
61+// request rather than retaining an unbounded metadata reorder queue.
62+func (s outputApplyState) nextView(update protocol.UIViewUpdate) (next outputApplyState, accepted, reset bool) {
63+ if update.Context.Validate() != nil || update.Epoch == 0 || update.State == 0 || !s.initialized {
64+ return s, false, true
65+ }
66+ if update.Epoch < s.epoch || update.Epoch == s.epoch && update.State < s.state || update.Context.Publication <= s.context.Publication {
67+ return s, false, false
68+ }
69+ if update.Epoch != s.epoch || update.State != s.state || update.Context.Route.Target.LifecycleID != s.context.Route.Target.LifecycleID {
70+ return s, false, true
71+ }
72+ s.context = update.Context
73+ return s, true, false
74+}
75+
76+func (s outputApplyState) uiContext(identity ports.UIContext, status ports.UIPresentationStatus) ports.UIContext {
77+ return ports.UIContext{
78+ AttachmentHandle: identity.AttachmentHandle, Generation: identity.Generation,
79+ Route: s.context.Route, TabID: s.context.TabID, FocusedPaneID: s.context.FocusedPaneID,
80+ OutputEpoch: s.epoch, OutputState: s.state, ViewRevision: s.viewRevision,
81+ ViewPublication: s.context.Publication, Status: status,
50 }82 }
51- return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
52 }83 }
M internal/usecase/client/output_state_test.go +10 -0
@@ -29,6 +29,16 @@ func TestOutputApplyStateTransitions(t *testing.T) {
29 }29 }
30 for _, tt := range tests {30 for _, tt := range tests {
31 t.Run(tt.name, func(t *testing.T) {31 t.Run(tt.name, func(t *testing.T) {
32+ if tt.state.initialized {
33+ tt.state.context = testOutputView(1)
34+ }
35+ if tt.output.New != 0 {
36+ context := testOutputView(2)
37+ tt.output.Context = &context
38+ tt.want.context = context
39+ } else {
40+ tt.want.context = tt.state.context
41+ }
32 got, accepted := tt.state.next(tt.output)42 got, accepted := tt.state.next(tt.output)
33 if accepted != tt.accepted {43 if accepted != tt.accepted {
34 t.Fatalf("accepted = %v, want %v", accepted, tt.accepted)44 t.Fatalf("accepted = %v, want %v", accepted, tt.accepted)
A internal/usecase/client/output_view_test.go +104 -0
@@ -0,0 +1,104 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func testOutputView(publication uint64) protocol.ViewContext {
13+ return protocol.ViewContext{
14+ Publication: publication,
15+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
16+ TabID: "tab-1", FocusedPaneID: "pane-1",
17+ }
18+}
19+
20+func TestOutputApplyCapturedViewContext(t *testing.T) {
21+ context := testOutputView(1)
22+ initial, accepted := (outputApplyState{}).next(protocol.Output{Epoch: 2, New: 1, Full: true, ViewRevision: 3, Context: &context})
23+ require.True(t, accepted)
24+ require.Equal(t, context, initial.context)
25+ context.FocusedPaneID = "changed after acceptance"
26+ require.Equal(t, testOutputView(1), initial.context, "accepted metadata must not alias received pointers")
27+ for _, scenario := range []string{"delta", "missing context", "stale publication", "invalid context", "different session", "contextual side effect", "plain side effect"} {
28+ t.Run(scenario, func(t *testing.T) {
29+ context := testOutputView(2)
30+ output := protocol.Output{Epoch: 2, Base: 1, New: 2, ViewRevision: 3, Context: &context}
31+ switch scenario {
32+ case "missing context":
33+ output.Context = nil
34+ case "different session":
35+ context.Route.Target.LifecycleID = domain.SessionLifecycleID{2}
36+ case "stale publication":
37+ context.Publication = 1
38+ case "invalid context":
39+ context.FocusedPaneID = ""
40+ case "contextual side effect":
41+ output.Base, output.New = 0, 0
42+ case "plain side effect":
43+ output.Base, output.New, output.Epoch = 0, 0, 9
44+ output.Context = nil
45+ }
46+ next, accepted := initial.next(output)
47+ switch scenario {
48+ case "delta":
49+ require.True(t, accepted)
50+ require.Equal(t, context, next.context)
51+ case "plain side effect":
52+ require.True(t, accepted)
53+ require.Equal(t, initial, next, "query/cleanup bytes cannot overwrite committed view identity")
54+ default:
55+ require.False(t, accepted)
56+ }
57+ })
58+ }
59+}
60+
61+func TestOutputApplyMetadataOnlyUpdates(t *testing.T) {
62+ initial := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)}
63+ for _, test := range []struct {
64+ name string
65+ initialized bool
66+ epoch, state, publication uint64
67+ accepted, reset bool
68+ }{
69+ {"same output new focus", true, 2, 4, 8, true, false},
70+ {"before initialization", false, 2, 4, 8, false, true},
71+ {"duplicate", true, 2, 4, 7, false, false},
72+ {"stale publication", true, 2, 4, 6, false, false},
73+ {"stale epoch", true, 1, 4, 8, false, false},
74+ {"stale output", true, 2, 3, 8, false, false},
75+ {"future epoch", true, 3, 4, 8, false, true},
76+ {"future output", true, 2, 5, 8, false, true},
77+ } {
78+ t.Run(test.name, func(t *testing.T) {
79+ state := initial
80+ state.initialized = test.initialized
81+ view := testOutputView(test.publication)
82+ view.FocusedPaneID = "pane-2"
83+ next, accepted, reset := state.nextView(protocol.UIViewUpdate{Epoch: test.epoch, State: test.state, Context: view})
84+ require.Equal(t, test.accepted, accepted)
85+ require.Equal(t, test.reset, reset)
86+ if accepted {
87+ require.Equal(t, initial.epoch, next.epoch)
88+ require.Equal(t, initial.state, next.state)
89+ require.Equal(t, initial.viewRevision, next.viewRevision)
90+ require.Equal(t, view, next.context)
91+ }
92+ })
93+ }
94+}
95+
96+func TestOutputUIContextUsesOnlyCapturedIdentity(t *testing.T) {
97+ state := outputApplyState{epoch: 2, state: 4, viewRevision: 3, initialized: true, context: testOutputView(7)}
98+ got := state.uiContext(ports.UIContext{AttachmentHandle: "public", Generation: 5}, ports.UIStatusReconnecting)
99+ require.Equal(t, ports.UIContext{
100+ AttachmentHandle: "public", Generation: 5, Route: state.context.Route,
101+ TabID: state.context.TabID, FocusedPaneID: state.context.FocusedPaneID,
102+ OutputEpoch: 2, OutputState: 4, ViewRevision: 3, ViewPublication: 7, Status: ports.UIStatusReconnecting,
103+ }, got)
104+}
M internal/usecase/client/palette_generation.go +15 -5
@@ -1,6 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4+ "sync/atomic"
4 "time"5 "time"
5 6
6 renderer "github.com/bnema/vev-vt"7 renderer "github.com/bnema/vev-vt"
@@ -53,14 +54,23 @@ const (
53 paletteEventBackground54 paletteEventBackground
54 paletteEventPalette55 paletteEventPalette
55 paletteEventScheme56 paletteEventScheme
57+ paletteEventUIBatchEnd
56 )58 )
57 59
58 type paletteGenerationEvent struct {60 type paletteGenerationEvent struct {
59- id paletteGenerationID61+ id paletteGenerationID
60- kind paletteGenerationEventKind62+ kind paletteGenerationEventKind
61- rgb renderer.RGB63+ rgb renderer.RGB
62- slot uint864+ slot uint8
63- light bool65+ light bool
66+ batchApplied chan struct{}
67+ pending *atomic.Int64
68+}
69+
70+func (e paletteGenerationEvent) applied() {
71+ if e.pending != nil {
72+ e.pending.Add(-1)
73+ }
64 }74 }
65 75
66 // paletteGenerationAction is deliberately declarative. The attach loop is76 // paletteGenerationAction is deliberately declarative. The attach loop is
A internal/usecase/client/paste_batch_test.go +46 -0
@@ -0,0 +1,46 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestPasteCoalescerBatchBoundaryPreservesOrdinaryBytes(t *testing.T) {
10+ for _, input := range []string{"", "hello", "\x1b", "word\x1b[20"} {
11+ t.Run(input, func(t *testing.T) {
12+ coalescer, _, collector := newTestCoalescer()
13+ t.Cleanup(coalescer.Close)
14+ require.True(t, coalescer.Idle())
15+ coalescer.Scan([]byte(input))
16+ require.True(t, coalescer.EndBatch())
17+ require.True(t, coalescer.Idle())
18+ var got string
19+ for _, emitted := range collector.snapshot() {
20+ got += string(emitted)
21+ }
22+ require.Equal(t, input, got)
23+ before := collector.snapshot()
24+ require.True(t, coalescer.EndBatch())
25+ require.Equal(t, before, collector.snapshot(), "batch end cannot duplicate an already emitted prefix")
26+ coalescer.Scan([]byte("next"))
27+ require.Equal(t, []byte("next"), collector.snapshot()[len(before)], "batch end must not close the input owner")
28+ })
29+ }
30+}
31+
32+func TestPasteCoalescerBatchBoundaryDoesNotTerminateHumanPaste(t *testing.T) {
33+ coalescer, _, collector := newTestCoalescer()
34+ t.Cleanup(coalescer.Close)
35+ coalescer.Scan([]byte("\x1b[200~human"))
36+ require.False(t, coalescer.Idle())
37+ require.False(t, coalescer.EndBatch(), "automation cannot end somebody else's open paste")
38+ require.Empty(t, collector.snapshot())
39+ require.True(t, coalescer.Buffering())
40+ coalescer.Scan([]byte("\x1b[201~"))
41+ require.True(t, coalescer.Idle())
42+ require.Equal(t, [][]byte{[]byte("\x1b[200~human\x1b[201~")}, collector.snapshot())
43+ coalescer.Close()
44+ require.False(t, coalescer.Idle())
45+ require.False(t, coalescer.EndBatch())
46+}
M internal/usecase/client/paste_coalescer.go +26 -0
@@ -158,6 +158,32 @@ func (c *pasteCoalescer) Buffering() bool {
158 return c.buffering || len(c.pending) > 0158 return c.buffering || len(c.pending) > 0
159 }159 }
160 160
161+// Idle is the automation admission check. It observes both paste and ordinary
162+// prefix ownership without flushing human input to manufacture a clean boundary.
163+func (c *pasteCoalescer) Idle() bool {
164+ c.mu.Lock()
165+ defer c.mu.Unlock()
166+ return !c.closed && !c.buffering && len(c.pending) == 0 && len(c.buf) == 0
167+}
168+
169+// EndBatch flushes only ordinary undecided bytes after a validated automated
170+// batch. An open paste is never terminated here; admission and input validation
171+// must prevent automation from entering or inheriting one.
172+func (c *pasteCoalescer) EndBatch() bool {
173+ c.mu.Lock()
174+ defer c.mu.Unlock()
175+ if c.closed || c.buffering {
176+ return false
177+ }
178+ c.stopFlushTimer()
179+ pending := c.pending
180+ c.pending = nil
181+ if len(pending) != 0 {
182+ c.emit(pending)
183+ }
184+ return true
185+}
186+
161 // Close stops any pending timers and their goroutines. Callers which hand input187 // Close stops any pending timers and their goroutines. Callers which hand input
162 // to a replacement must first use CloseAndTakeHeld so undecided bytes survive.188 // to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
163 func (c *pasteCoalescer) Close() {189 func (c *pasteCoalescer) Close() {
M internal/usecase/client/same_peer_switch.go +33 -3
@@ -3,7 +3,9 @@ package client
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "sync/atomic"
6 7
8+ "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
8 )10 )
9 11
@@ -11,9 +13,11 @@ import (
11 // not drop or reparse bytes: once the daemon commits, queued input follows the13 // not drop or reparse bytes: once the daemon commits, queued input follows the
12 // attachment to the target; a typed pre-commit failure releases it to source.14 // attachment to the target; a typed pre-commit failure releases it to source.
13 type samePeerInputGate struct {15 type samePeerInputGate struct {
14- mu sync.Mutex16+ ui *UI
15- paused bool17+ retiredUI atomic.Uint64
16- changed chan struct{}18+ mu sync.Mutex
19+ paused bool
20+ changed chan struct{}
17 // afterInputHeld is a deterministic test synchronization seam.21 // afterInputHeld is a deterministic test synchronization seam.
18 afterInputHeld func()22 afterInputHeld func()
19 }23 }
@@ -40,6 +44,32 @@ func (g *samePeerInputGate) setPaused(paused bool) {
40 g.mu.Unlock()44 g.mu.Unlock()
41 }45 }
42 46
47+// discardRetiredUI prevents already queued automation from following human
48+// input across an in-band route switch. Dropping a fence alone is harmless;
49+// dropping normal input makes its accepted action's delivery ambiguous.
50+func (g *samePeerInputGate) discardRetiredUI(message protocol.ClientMessage) bool {
51+ if g == nil || g.retiredUI.Load() == 0 {
52+ return false
53+ }
54+ var actionID uint64
55+ input := false
56+ switch message := message.(type) {
57+ case protocol.Input:
58+ actionID, input = message.ActionID, true
59+ case protocol.UIFence:
60+ actionID = message.ActionID
61+ }
62+ if actionID == 0 || actionID > g.retiredUI.Load() {
63+ return false
64+ }
65+ if input && g.ui != nil {
66+ g.ui.mu.Lock()
67+ g.ui.finishLocked(actionID, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
68+ g.ui.mu.Unlock()
69+ }
70+ return true
71+}
72+
43 func (g *samePeerInputGate) snapshot() (bool, <-chan struct{}) {73 func (g *samePeerInputGate) snapshot() (bool, <-chan struct{}) {
44 if g == nil {74 if g == nil {
45 return false, nil75 return false, nil
M internal/usecase/client/session_connection.go +3 -0
@@ -76,6 +76,9 @@ func (c *SessionConnection) AttachRequest() AttachRequest {
76 // cloneAttachRequest keeps the immutable request independent from transport76 // cloneAttachRequest keeps the immutable request independent from transport
77 // ownership while protecting the exact remote target from caller mutation.77 // ownership while protecting the exact remote target from caller mutation.
78 func cloneAttachRequest(request AttachRequest) AttachRequest {78 func cloneAttachRequest(request AttachRequest) AttachRequest {
79+ if request.Environment != nil {
80+ request.Environment = append(make([]string, 0, len(request.Environment)), request.Environment...)
81+ }
79 if request.RemoteTarget != nil {82 if request.RemoteTarget != nil {
80 target := *request.RemoteTarget83 target := *request.RemoteTarget
81 request.RemoteTarget = &target84 request.RemoteTarget = &target
A internal/usecase/client/session_connection_internal_test.go +33 -0
@@ -0,0 +1,33 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestCloneAttachRequestPreservesEnvironmentShape(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ environment []string
13+ }{
14+ {name: "nil", environment: nil},
15+ {name: "empty", environment: []string{}},
16+ {name: "non-empty", environment: []string{"TERM=xterm"}},
17+ }
18+ for _, test := range tests {
19+ t.Run(test.name, func(t *testing.T) {
20+ request := cloneAttachRequest(AttachRequest{Environment: test.environment})
21+ if test.environment == nil {
22+ require.Nil(t, request.Environment)
23+ return
24+ }
25+ require.NotNil(t, request.Environment)
26+ require.Equal(t, test.environment, request.Environment)
27+ if len(test.environment) > 0 {
28+ test.environment[0] = "changed"
29+ require.Equal(t, "TERM=xterm", request.Environment[0])
30+ }
31+ })
32+ }
33+}
A internal/usecase/client/ui.go +300 -0
@@ -0,0 +1,300 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "sync"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+const uiActionHistory = 64
17+
18+// UI owns attachment-local action and waiter bookkeeping. It never holds its
19+// mutex across terminal access, transport I/O, or predicate evaluation.
20+type UI struct {
21+ state ports.UIState
22+ clock ports.Clock
23+ handle string
24+ mu sync.Mutex
25+ changed chan struct{}
26+ generation uint64
27+ input *terminalInputPump
28+ consumer uint64
29+ foreground context.Context
30+ records map[uint64]ports.UIActionResult
31+ order []uint64
32+ nextAction uint64
33+ pending uint64
34+ reservedContext ports.UIContext
35+ waits int
36+ boundary ports.UIActionResult
37+ dispatched map[uint64]bool
38+ completion map[uint64]chan struct{}
39+ handoff *uiActionHandoff
40+}
41+
42+type uiActionHandoff struct {
43+ actionID uint64
44+ sourceGeneration uint64
45+ destinationGeneration uint64
46+ boundary ports.UIActionResult
47+}
48+
49+func NewUI(state ports.UIState, clock ports.Clock) *UI {
50+ if clock == nil {
51+ clock = systemClock{}
52+ }
53+ return &UI{state: state, clock: clock, handle: fmt.Sprintf("%x", newClientID()), changed: make(chan struct{}), records: make(map[uint64]ports.UIActionResult), dispatched: make(map[uint64]bool), completion: make(map[uint64]chan struct{})}
54+}
55+
56+func (u *UI) Handle() string { return u.handle }
57+
58+// WaitForSnapshot waits on the UI owner's broadcast signal rather than
59+// consuming the terminal's single coalesced state channel. The predicate is
60+// evaluated against the latest owned snapshot and never under the UI mutex.
61+func (u *UI) WaitForSnapshot(ctx context.Context, match func(ports.UISnapshot) bool) (ports.UISnapshot, error) {
62+ if match == nil {
63+ return ports.UISnapshot{}, ports.ErrUIUnavailable
64+ }
65+ var lastSnapshotErr error
66+ for {
67+ if err := ctx.Err(); err != nil {
68+ if lastSnapshotErr != nil {
69+ return ports.UISnapshot{}, errors.Join(err, lastSnapshotErr)
70+ }
71+ return ports.UISnapshot{}, err
72+ }
73+ snapshot, err := u.state.Snapshot()
74+ if err == nil {
75+ lastSnapshotErr = nil
76+ if match(snapshot) {
77+ return snapshot, nil
78+ }
79+ } else {
80+ lastSnapshotErr = err
81+ }
82+ u.mu.Lock()
83+ changed := u.changed
84+ u.mu.Unlock()
85+ select {
86+ case <-ctx.Done():
87+ if lastSnapshotErr != nil {
88+ return ports.UISnapshot{}, errors.Join(ctx.Err(), lastSnapshotErr)
89+ }
90+ return ports.UISnapshot{}, ctx.Err()
91+ case <-changed:
92+ }
93+ }
94+}
95+
96+// ActionComplete returns a closed channel once an accepted action reaches a
97+// terminal status. It is an optional lifecycle seam for adapters enforcing
98+// attachment-wide action admission after a request timeout.
99+func (u *UI) ActionComplete(actionID uint64) <-chan struct{} {
100+ if actionID == 0 {
101+ return nil
102+ }
103+ u.mu.Lock()
104+ defer u.mu.Unlock()
105+ if done, ok := u.completion[actionID]; ok {
106+ return done
107+ }
108+ if record, ok := u.records[actionID]; ok && record.Status != ports.UIActionPending {
109+ done := make(chan struct{})
110+ close(done)
111+ return done
112+ }
113+ return nil
114+}
115+
116+func (u *UI) status(status ports.UIPresentationStatus) {
117+ publication, ok := u.state.(ports.UIOutputTransaction)
118+ if !ok {
119+ return
120+ }
121+ snapshot, err := u.state.Snapshot()
122+ if err != nil {
123+ return
124+ }
125+ u.mu.Lock()
126+ generation := u.generation
127+ u.mu.Unlock()
128+ snapshot.Context.AttachmentHandle = u.handle
129+ snapshot.Context.Generation = generation
130+ snapshot.Context.Status = status
131+ _ = publication.PublishContext(snapshot.Context) // Unavailable capture remains unavailable.
132+}
133+
134+// Observe relays the sink's single coalesced signal to bounded concurrent
135+// waiters. The Runner owns this worker's lifetime.
136+func (u *UI) Observe(ctx context.Context) {
137+ defer func() {
138+ u.mu.Lock()
139+ if u.pending != 0 {
140+ u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
141+ }
142+ u.mu.Unlock()
143+ }()
144+ for {
145+ select {
146+ case <-ctx.Done():
147+ return
148+ case <-u.state.Changes():
149+ u.mu.Lock()
150+ u.signalLocked()
151+ u.mu.Unlock()
152+ }
153+ }
154+}
155+
156+func (u *UI) signalLocked() { close(u.changed); u.changed = make(chan struct{}) }
157+
158+func (u *UI) Capture(attachment string) (ports.UISnapshot, error) {
159+ if attachment != u.handle {
160+ return ports.UISnapshot{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
161+ }
162+ return u.state.Snapshot()
163+}
164+
165+func uiTimeout(timeout time.Duration) (time.Duration, error) {
166+ if timeout == 0 {
167+ return 5 * time.Second, nil
168+ }
169+ if timeout < time.Millisecond || timeout > 30*time.Second {
170+ return 0, &ports.UIError{Code: ports.UIErrInvalidRequest}
171+ }
172+ return timeout, nil
173+}
174+
175+func validateUIExpect(expect ports.UIExpect) bool {
176+ if expect.TextContains == nil && expect.Session == nil && expect.Focus == nil && expect.Status == nil {
177+ return false
178+ }
179+ if expect.TextContains != nil && (len(*expect.TextContains) > 4096 || !utf8.ValidString(*expect.TextContains)) {
180+ return false
181+ }
182+ if expect.Session != nil && expect.Session.Validate() != nil {
183+ return false
184+ }
185+ if expect.Focus != nil && (domain.ValidateTabStableID(expect.Focus.TabID) != nil || domain.ValidatePaneStableID(expect.Focus.PaneID) != nil) {
186+ return false
187+ }
188+ if expect.Status != nil {
189+ switch *expect.Status {
190+ case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached:
191+ default:
192+ return false
193+ }
194+ }
195+ return true
196+}
197+
198+func uiMatches(snapshot ports.UISnapshot, expect ports.UIExpect) bool {
199+ if expect.Session != nil && snapshot.Context.Route.Target != *expect.Session {
200+ return false
201+ }
202+ if expect.Focus != nil && (snapshot.Context.TabID != expect.Focus.TabID || snapshot.Context.FocusedPaneID != expect.Focus.PaneID) {
203+ return false
204+ }
205+ if expect.Status != nil && snapshot.Context.Status != *expect.Status {
206+ return false
207+ }
208+ if expect.TextContains != nil && !strings.Contains(uiSnapshotText(snapshot), *expect.TextContains) {
209+ return false
210+ }
211+ return true
212+}
213+
214+func uiSnapshotText(snapshot ports.UISnapshot) string {
215+ var text strings.Builder
216+ for row := 0; row < snapshot.Rows; row++ {
217+ if row > 0 {
218+ text.WriteByte('\n')
219+ }
220+ for col := 0; col < snapshot.Columns; col++ {
221+ index := row*snapshot.Columns + col
222+ if index >= len(snapshot.Cells) {
223+ text.WriteByte(' ')
224+ continue
225+ }
226+ cell := snapshot.Cells[index]
227+ if cell.Continuation {
228+ continue
229+ }
230+ if cell.Text == "" {
231+ text.WriteByte(' ')
232+ } else {
233+ text.WriteString(cell.Text)
234+ }
235+ }
236+ }
237+ return text.String()
238+}
239+
240+func (u *UI) Wait(ctx context.Context, request ports.UIWaitRequest) (ports.UIWaitResult, error) {
241+ timeout, err := uiTimeout(request.Timeout)
242+ if err != nil || !validateUIExpect(request.Expect) {
243+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
244+ }
245+ if request.Attachment != u.handle {
246+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
247+ }
248+ u.mu.Lock()
249+ if u.waits >= 4 {
250+ u.mu.Unlock()
251+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrBusy}
252+ }
253+ u.waits++
254+ u.mu.Unlock()
255+ defer func() { u.mu.Lock(); u.waits--; u.mu.Unlock() }()
256+ timer := u.clock.NewTimer(timeout)
257+ defer timer.Stop()
258+ for {
259+ if err := ctx.Err(); err != nil {
260+ return ports.UIWaitResult{}, err
261+ }
262+ u.mu.Lock()
263+ changed := u.changed
264+ action, exists := u.records[request.AfterAction]
265+ generation := u.generation
266+ u.mu.Unlock()
267+ if request.AfterAction != 0 {
268+ if !exists {
269+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrActionExpired, ActionID: request.AfterAction}
270+ }
271+ if action.Status != ports.UIActionPending && action.Status != ports.UIActionProcessed {
272+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrorCode(action.Status), Accepted: action.Accepted, ActionID: action.ActionID}
273+ }
274+ if action.Status == ports.UIActionProcessed && action.Context.Generation != generation {
275+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrOutcomeUnknown, Accepted: true, ActionID: action.ActionID}
276+ }
277+ }
278+ snapshot, snapshotErr := u.Capture(request.Attachment)
279+ eligible := request.AfterAction == 0 || action.Status == ports.UIActionProcessed && snapshot.Revision >= action.Revision && snapshot.Context.Generation == action.Context.Generation
280+ if snapshotErr == nil && eligible && uiMatches(snapshot, request.Expect) {
281+ if request.AfterAction != 0 {
282+ u.mu.Lock()
283+ current := u.records[request.AfterAction]
284+ unchanged := current == action && u.generation == generation
285+ u.mu.Unlock()
286+ if !unchanged {
287+ continue
288+ }
289+ }
290+ return ports.UIWaitResult{ActionID: request.AfterAction, ActionStatus: action.Status, Revision: snapshot.Revision, Context: snapshot.Context}, nil
291+ }
292+ select {
293+ case <-ctx.Done():
294+ return ports.UIWaitResult{}, ctx.Err()
295+ case <-timer.C():
296+ return ports.UIWaitResult{}, &ports.UIError{Code: ports.UIErrTimeout, Accepted: action.Accepted, ActionID: request.AfterAction}
297+ case <-changed:
298+ }
299+ }
300+}
A internal/usecase/client/ui_actions.go +272 -0
@@ -0,0 +1,272 @@
1+package client
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// bindForeground runs at the existing foreground ownership boundary.
12+func (u *UI) bindForeground(ctx context.Context, input *terminalInputPump, consumer uint64) uint64 {
13+ u.mu.Lock()
14+ defer u.mu.Unlock()
15+ u.generation++
16+ u.input = input
17+ u.consumer = consumer
18+ u.foreground = ctx
19+ u.boundary = ports.UIActionResult{}
20+ if u.handoff != nil && u.handoff.destinationGeneration == 0 {
21+ u.handoff.destinationGeneration = u.generation
22+ } else if u.pending != 0 {
23+ u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
24+ u.pending = 0
25+ }
26+ u.signalLocked()
27+ return u.generation
28+}
29+
30+func (u *UI) accept(id, generation uint64) bool {
31+ u.mu.Lock()
32+ defer u.mu.Unlock()
33+ if u.pending != id || u.generation != generation {
34+ return false
35+ }
36+ if len(u.order) == uiActionHistory {
37+ evicted := u.order[0]
38+ delete(u.records, evicted)
39+ delete(u.dispatched, evicted)
40+ if done, ok := u.completion[evicted]; ok {
41+ close(done)
42+ delete(u.completion, evicted)
43+ }
44+ u.order = u.order[1:]
45+ }
46+ u.order = append(u.order, id)
47+ u.completion[id] = make(chan struct{})
48+ u.records[id] = ports.UIActionResult{ActionID: id, Accepted: true, Status: ports.UIActionPending, Context: u.reservedContext}
49+ u.signalLocked()
50+ return true
51+}
52+
53+func (u *UI) finishLocked(id uint64, status ports.UIActionStatus, boundary ports.UIActionResult) {
54+ record, ok := u.records[id]
55+ if !ok {
56+ return
57+ }
58+ record.Status = status
59+ if status == ports.UIActionProcessed {
60+ u.dispatched[id] = true
61+ record.Revision = boundary.Revision
62+ record.Context = boundary.Context
63+ }
64+ u.records[id] = record
65+ if done, ok := u.completion[id]; ok {
66+ close(done)
67+ delete(u.completion, id)
68+ }
69+ if u.handoff != nil && u.handoff.actionID == id {
70+ u.handoff = nil
71+ }
72+ if u.pending == id {
73+ u.pending = 0
74+ }
75+ u.signalLocked()
76+}
77+
78+// published remembers only a committed output boundary, not receipt receive
79+// time or an unrelated status-only revision.
80+func (u *UI) published(generation uint64) {
81+ snapshot, err := u.state.Snapshot()
82+ if err != nil {
83+ return
84+ }
85+ u.mu.Lock()
86+ defer u.mu.Unlock()
87+ if generation != u.generation || snapshot.Context.Generation != generation {
88+ return
89+ }
90+ u.boundary = ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context}
91+ u.signalLocked()
92+}
93+
94+func (u *UI) receipt(generation uint64, receipt protocol.UIReceipt) {
95+ u.mu.Lock()
96+ defer u.mu.Unlock()
97+ if u.handoff != nil && u.handoff.actionID == receipt.ActionID {
98+ return
99+ }
100+ record, ok := u.records[receipt.ActionID]
101+ if !ok || record.Status != ports.UIActionPending || record.Context.Generation != generation || generation != u.generation || receipt.Validate() != nil {
102+ return
103+ }
104+ if receipt.Outcome == protocol.UIReceiptUnavailable {
105+ u.finishLocked(receipt.ActionID, ports.UIActionUnavailable, ports.UIActionResult{})
106+ return
107+ }
108+ boundary := u.boundary
109+ if boundary.Context.OutputEpoch != receipt.Epoch || boundary.Context.OutputState != receipt.State || boundary.Context.ViewPublication != receipt.ViewPublication || boundary.Revision == 0 {
110+ return
111+ }
112+ u.finishLocked(receipt.ActionID, ports.UIActionProcessed, boundary)
113+}
114+
115+func (u *UI) Action(ctx context.Context, request ports.UIActionRequest) (ports.UIActionResult, error) {
116+ timeout, err := uiTimeout(request.Timeout)
117+ if err != nil {
118+ return ports.UIActionResult{}, err
119+ }
120+ if request.Attachment != u.handle {
121+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
122+ }
123+ snapshot, err := u.state.Snapshot()
124+ if err != nil {
125+ return ports.UIActionResult{}, err
126+ }
127+ if request.Generation == 0 || snapshot.Context.Generation != request.Generation {
128+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
129+ }
130+ var data []byte
131+ switch {
132+ case len(request.Keys) > 0 && request.Text == "":
133+ data, err = encodeUIKeys(request.Keys, snapshot.ApplicationCursor)
134+ case len(request.Keys) == 0 && request.Text != "":
135+ data, err = encodeUIText(request.Text)
136+ default:
137+ err = errUIInvalidInput
138+ }
139+ if err != nil {
140+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrInvalidRequest}
141+ }
142+ u.mu.Lock()
143+ if request.Generation != u.generation {
144+ u.mu.Unlock()
145+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrStaleAttachment}
146+ }
147+ if u.pending != 0 {
148+ u.mu.Unlock()
149+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrBusy}
150+ }
151+ if u.foreground == nil || u.foreground.Err() != nil || snapshot.Context.Status != ports.UIStatusAttached {
152+ u.mu.Unlock()
153+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrUnavailable}
154+ }
155+ u.nextAction++
156+ id := u.nextAction
157+ u.pending = id
158+ u.reservedContext = snapshot.Context
159+ input, consumer, foreground := u.input, u.consumer, u.foreground
160+ u.mu.Unlock()
161+ timer := u.clock.NewTimer(timeout)
162+ defer timer.Stop()
163+ admissionCtx, cancelAdmission := context.WithCancel(ctx)
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)}
166+ reject := func(code ports.UIErrorCode) (ports.UIActionResult, error) {
167+ u.mu.Lock()
168+ delete(u.records, id)
169+ if u.pending == id {
170+ u.pending = 0
171+ }
172+ for i, entry := range u.order {
173+ if entry == id {
174+ u.order = append(u.order[:i], u.order[i+1:]...)
175+ break
176+ }
177+ }
178+ u.signalLocked()
179+ u.mu.Unlock()
180+ return ports.UIActionResult{}, &ports.UIError{Code: code}
181+ }
182+ select {
183+ case input.automation <- batch:
184+ case <-ctx.Done():
185+ return reject(ports.UIErrTimeout)
186+ case <-foreground.Done():
187+ return reject(ports.UIErrUnavailable)
188+ case <-timer.C():
189+ return reject(ports.UIErrTimeout)
190+ }
191+ // Once the owner receives the request it returns admission without I/O. Learn
192+ // that decision even if cancellation races it; never claim rollback of input.
193+ admitted := <-batch.admitted
194+ if !admitted {
195+ return reject(ports.UIErrInputBusy)
196+ }
197+ go u.trackDispatch(foreground, id, batch.dispatched)
198+ for {
199+ u.mu.Lock()
200+ record, exists := u.records[id]
201+ changed := u.changed
202+ u.mu.Unlock()
203+ if !exists {
204+ return ports.UIActionResult{}, &ports.UIError{Code: ports.UIErrActionExpired, Accepted: true, ActionID: id}
205+ }
206+ if record.Status == ports.UIActionProcessed {
207+ return record, nil
208+ }
209+ if record.Status != ports.UIActionPending {
210+ return record, &ports.UIError{Code: ports.UIErrorCode(record.Status), Accepted: true, ActionID: id}
211+ }
212+ select {
213+ case <-changed:
214+ case <-ctx.Done():
215+ return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
216+ case <-timer.C():
217+ return record, &ports.UIError{Code: ports.UIErrTimeout, Accepted: true, ActionID: id}
218+ }
219+ }
220+}
221+
222+func (u *UI) trackDispatch(ctx context.Context, id uint64, dispatched <-chan bool) {
223+ timer := u.clock.NewTimer(30 * time.Second)
224+ defer timer.Stop()
225+ foregroundDone := ctx.Done()
226+ for {
227+ u.mu.Lock()
228+ record, exists := u.records[id]
229+ changed := u.changed
230+ u.mu.Unlock()
231+ if !exists || record.Status != ports.UIActionPending {
232+ return
233+ }
234+ var status ports.UIActionStatus
235+ select {
236+ case ok := <-dispatched:
237+ if !ok {
238+ status = ports.UIActionOutcomeUnknown
239+ } else {
240+ u.mu.Lock()
241+ if _, exists := u.records[id]; exists {
242+ u.dispatched[id] = true
243+ u.completeHandoffLocked()
244+ }
245+ u.mu.Unlock()
246+ }
247+ dispatched = nil
248+ case <-foregroundDone:
249+ u.mu.Lock()
250+ following := u.handoff != nil && u.handoff.actionID == id
251+ u.mu.Unlock()
252+ if following {
253+ foregroundDone = nil
254+ } else {
255+ status = ports.UIActionOutcomeUnknown
256+ }
257+ case <-timer.C():
258+ status = ports.UIActionUnavailable
259+ case <-changed:
260+ }
261+ if status != "" {
262+ u.mu.Lock()
263+ if current := u.records[id]; current.Status == ports.UIActionPending {
264+ u.finishLocked(id, status, ports.UIActionResult{})
265+ }
266+ u.mu.Unlock()
267+ return
268+ }
269+ }
270+}
271+
272+var _ ports.UIService = (*UI)(nil)
A internal/usecase/client/ui_handoff.go +82 -0
@@ -0,0 +1,82 @@
1+package client
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// follow joins an immutable navigation cause to the Runner's existing handoff.
10+// It selects no route and never retries or opens a connection itself.
11+func (u *UI) follow(generation, actionID uint64) bool {
12+ var dispatchContext context.Context
13+ startDispatch := false
14+ u.mu.Lock()
15+ record, exists := u.records[actionID]
16+ if actionID == 0 || !exists || generation != u.generation || record.Context.Generation != generation || record.Status != ports.UIActionPending && record.Status != ports.UIActionProcessed {
17+ u.mu.Unlock()
18+ return false
19+ }
20+ if u.pending != 0 && u.pending != actionID {
21+ u.finishLocked(u.pending, ports.UIActionOutcomeUnknown, ports.UIActionResult{})
22+ }
23+ if record.Status == ports.UIActionProcessed {
24+ dispatchContext = u.foreground
25+ if dispatchContext == nil {
26+ dispatchContext = context.Background()
27+ }
28+ startDispatch = true
29+ }
30+ record.Status = ports.UIActionPending
31+ u.records[actionID] = record
32+ u.pending = actionID
33+ u.handoff = &uiActionHandoff{actionID: actionID, sourceGeneration: generation}
34+ u.signalLocked()
35+ u.mu.Unlock()
36+ if startDispatch {
37+ go u.trackDispatch(dispatchContext, actionID, nil)
38+ }
39+ return true
40+}
41+
42+// completeLocal runs after the foreground's FIFO callback barrier, without
43+// inventing a daemon fence for an action consumed entirely by the client.
44+func (u *UI) completeLocal(generation, actionID uint64) {
45+ snapshot, err := u.state.Snapshot()
46+ if err != nil {
47+ return
48+ }
49+ u.mu.Lock()
50+ defer u.mu.Unlock()
51+ record, exists := u.records[actionID]
52+ if !exists || record.Status != ports.UIActionPending || generation != u.generation || snapshot.Context.Generation != generation || u.handoff != nil {
53+ return
54+ }
55+ u.finishLocked(actionID, ports.UIActionProcessed, ports.UIActionResult{Revision: snapshot.Revision, Context: snapshot.Context})
56+}
57+
58+func (u *UI) failHandoff() {
59+ u.mu.Lock()
60+ defer u.mu.Unlock()
61+ if u.handoff != nil {
62+ u.finishLocked(u.handoff.actionID, ports.UIActionNavigationFailed, ports.UIActionResult{})
63+ }
64+}
65+
66+// destinationFull is called only after the existing handoff owner validates,
67+// writes and publishes its destination full Output. Identity alone is not proof.
68+func (u *UI) destinationFull(generation uint64) {
69+ u.mu.Lock()
70+ defer u.mu.Unlock()
71+ if u.handoff == nil || u.handoff.destinationGeneration != generation || generation == u.handoff.sourceGeneration || u.boundary.Context.Generation != generation || u.boundary.Context.Status != ports.UIStatusAttached {
72+ return
73+ }
74+ u.handoff.boundary = u.boundary
75+ u.completeHandoffLocked()
76+}
77+
78+func (u *UI) completeHandoffLocked() {
79+ if u.handoff != nil && u.handoff.boundary.Revision != 0 && u.dispatched[u.handoff.actionID] {
80+ u.finishLocked(u.handoff.actionID, ports.UIActionProcessed, u.handoff.boundary)
81+ }
82+}
A internal/usecase/client/ui_handoff_test.go +100 -0
@@ -0,0 +1,100 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestUIHandoffRequiresDestinationFullAndCompleteDispatch(t *testing.T) {
13+ for _, dispatchFirst := range []bool{false, true} {
14+ t.Run(map[bool]string{false: "full first", true: "dispatch first"}[dispatchFirst], func(t *testing.T) {
15+ u, terminal, _, ctx := newUITestService(t)
16+ snapshot, err := terminal.Snapshot()
17+ require.NoError(t, err)
18+ u.pending = 1
19+ u.reservedContext = snapshot.Context
20+ require.True(t, u.accept(1, 1))
21+ u.dispatched[1] = dispatchFirst
22+ require.True(t, u.follow(1, 1))
23+ u.receipt(1, protocol.UIReceipt{ActionID: 1, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed})
24+ require.Equal(t, ports.UIActionPending, u.records[1].Status, "source receipt cannot complete a correlated handoff")
25+ generation := u.bindForeground(ctx, u.input, u.consumer)
26+ require.Equal(t, uint64(2), generation)
27+ view := snapshot.Context
28+ view.Generation = generation
29+ view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"}
30+ view.OutputEpoch = 2
31+ require.NoError(t, terminal.PublishContext(view))
32+ u.published(generation)
33+ require.Equal(t, ports.UIActionPending, u.records[1].Status, "route identity or metadata is not destination full output")
34+ u.destinationFull(generation)
35+ if !dispatchFirst {
36+ require.Equal(t, ports.UIActionPending, u.records[1].Status)
37+ u.dispatched[1] = true
38+ u.completeHandoffLocked()
39+ }
40+ require.Equal(t, ports.UIActionProcessed, u.records[1].Status)
41+ require.Equal(t, generation, u.records[1].Context.Generation)
42+ require.Equal(t, "destination", u.records[1].Context.Route.Target.SessionName)
43+ })
44+ }
45+}
46+
47+func TestUILocalCompletionUsesAppliedCallbackBoundary(t *testing.T) {
48+ u, terminal, _, _ := newUITestService(t)
49+ snapshot, err := terminal.Snapshot()
50+ require.NoError(t, err)
51+ u.pending = 1
52+ u.reservedContext = snapshot.Context
53+ require.True(t, u.accept(1, 1))
54+ u.completeLocal(2, 1)
55+ require.Equal(t, ports.UIActionPending, u.records[1].Status)
56+ u.completeLocal(1, 1)
57+ require.Equal(t, ports.UIActionProcessed, u.records[1].Status)
58+ require.Equal(t, snapshot.Revision, u.records[1].Revision)
59+}
60+
61+func TestUILateHandoffAndUnrelatedReplacement(t *testing.T) {
62+ u, terminal, _, ctx := newUITestService(t)
63+ snapshot, err := terminal.Snapshot()
64+ require.NoError(t, err)
65+ u.pending = 1
66+ u.reservedContext = snapshot.Context
67+ require.True(t, u.accept(1, 1))
68+ u.receipt(1, protocol.UIReceipt{ActionID: 1, Epoch: 1, State: 1, ViewPublication: 1, Outcome: protocol.UIReceiptProcessed})
69+ require.Equal(t, ports.UIActionProcessed, u.records[1].Status)
70+ require.True(t, u.follow(1, 1), "a deferred cause retains its original action")
71+ u.failHandoff()
72+ require.Equal(t, ports.UIActionNavigationFailed, u.records[1].Status)
73+ u.pending = 2
74+ u.reservedContext = snapshot.Context
75+ require.True(t, u.accept(2, 1))
76+ require.False(t, u.follow(1, 0))
77+ u.bindForeground(ctx, u.input, u.consumer)
78+ require.Equal(t, ports.UIActionOutcomeUnknown, u.records[2].Status)
79+}
80+
81+func TestSamePeerGateDiscardsOnlyRetiredAutomatedInput(t *testing.T) {
82+ u, terminal, _, _ := newUITestService(t)
83+ snapshot, err := terminal.Snapshot()
84+ require.NoError(t, err)
85+ u.pending = 1
86+ u.reservedContext = snapshot.Context
87+ require.True(t, u.accept(1, 1))
88+ require.True(t, u.follow(1, 1))
89+ gate := newSamePeerInputGate()
90+ gate.ui = u
91+ gate.retiredUI.Store(1)
92+ require.True(t, gate.discardRetiredUI(protocol.UIFence{ActionID: 1}))
93+ require.Equal(t, ports.UIActionPending, u.records[1].Status, "a superseded source fence is not lost input")
94+ require.False(t, gate.discardRetiredUI(protocol.Input{Data: []byte("human")}))
95+ require.False(t, gate.discardRetiredUI(protocol.Input{ActionID: 2, Data: []byte("new generation")}))
96+ require.True(t, gate.discardRetiredUI(protocol.Input{ActionID: 1, Data: []byte("unsent suffix")}))
97+ require.Equal(t, ports.UIActionOutcomeUnknown, u.records[1].Status)
98+ require.Nil(t, u.handoff)
99+ require.False(t, gate.discardRetiredUI(protocol.Ack{Epoch: 1, State: 1}))
100+}
A internal/usecase/client/ui_input.go +168 -0
@@ -0,0 +1,168 @@
1+package client
2+
3+import (
4+ "errors"
5+ "strings"
6+ "unicode/utf8"
7+)
8+
9+const uiMaxInputBytes = 16 << 10
10+
11+var errUIInvalidInput = errors.New("invalid UI input")
12+
13+// encodeUIText accepts literal printable UTF-8, never terminal controls.
14+func encodeUIText(text string) ([]byte, error) {
15+ if text == "" || len(text) > uiMaxInputBytes || !utf8.ValidString(text) {
16+ return nil, errUIInvalidInput
17+ }
18+ for _, r := range text {
19+ if r < 0x20 || r >= 0x7f && r <= 0x9f {
20+ return nil, errUIInvalidInput
21+ }
22+ }
23+ return []byte(text), nil
24+}
25+
26+// encodeUIKeys validates the complete batch as well as each token: individually
27+// legal Escape and ASCII tokens must not assemble a terminal reply or paste.
28+func encodeUIKeys(keys []string, applicationCursor bool) ([]byte, error) {
29+ if len(keys) == 0 || len(keys) > 256 {
30+ return nil, errUIInvalidInput
31+ }
32+ var out []byte
33+ for _, key := range keys {
34+ encoded, ok := encodeUIKey(key, applicationCursor)
35+ if !ok || len(out)+len(encoded) > uiMaxInputBytes {
36+ return nil, errUIInvalidInput
37+ }
38+ out = append(out, encoded...)
39+ }
40+ if !validUIKeyBatch(out) {
41+ return nil, errUIInvalidInput
42+ }
43+ return out, nil
44+}
45+
46+func encodeUIKey(key string, applicationCursor bool) (string, bool) {
47+ if strings.HasPrefix(key, "Alt+") {
48+ plain := strings.TrimPrefix(key, "Alt+")
49+ switch plain {
50+ case "Enter", "Escape", "Tab", "Backspace", "Space":
51+ encoded, _ := encodeUIKey(plain, applicationCursor)
52+ return "\x1b" + encoded, true
53+ }
54+ if len(plain) == 1 && plain[0] >= 0x20 && plain[0] <= 0x7e {
55+ return "\x1b" + plain, true
56+ }
57+ return "", false
58+ }
59+ if strings.HasPrefix(key, "Ctrl+") {
60+ plain := strings.TrimPrefix(key, "Ctrl+")
61+ if len(plain) == 1 && (plain[0] >= 'A' && plain[0] <= 'Z' || strings.ContainsRune("@[\\]^_", rune(plain[0]))) {
62+ return string([]byte{plain[0] & 31}), true
63+ }
64+ return "", false
65+ }
66+ if len(key) == 1 && key[0] >= 0x20 && key[0] <= 0x7e {
67+ return key, true
68+ }
69+ switch key {
70+ case "Enter":
71+ return "\r", true
72+ case "Escape":
73+ return "\x1b", true
74+ case "Tab":
75+ return "\t", true
76+ case "Backspace":
77+ return "\x7f", true
78+ case "Space":
79+ return " ", true
80+ case "PageUp":
81+ return "\x1b[5~", true
82+ case "PageDown":
83+ return "\x1b[6~", true
84+ }
85+ var final string
86+ switch key {
87+ case "Up":
88+ final = "A"
89+ case "Down":
90+ final = "B"
91+ case "Right":
92+ final = "C"
93+ case "Left":
94+ final = "D"
95+ case "Home":
96+ final = "H"
97+ case "End":
98+ final = "F"
99+ default:
100+ return "", false
101+ }
102+ prefix := "\x1b["
103+ if applicationCursor {
104+ prefix = "\x1bO"
105+ }
106+ return prefix + final, true
107+}
108+
109+func validUIKeyBatch(data []byte) bool {
110+ for i := 0; i < len(data); i++ {
111+ if data[i] != 0x1b {
112+ continue
113+ }
114+ // A bare Escape is the only intentionally incomplete escape sequence.
115+ if i+1 == len(data) {
116+ continue
117+ }
118+ switch data[i+1] {
119+ case ']', 'P', '_', '^', 'X':
120+ // A lone Alt character is valid, but adding a suffix would turn
121+ // it into an OSC/DCS/APC/string prefix.
122+ if i+2 < len(data) {
123+ return false
124+ }
125+ continue
126+ case '[':
127+ // Alt+[ is a valid one-character key. Once it has a suffix, only
128+ // one of the finite CSI keyboard sequences is accepted.
129+ if i+2 >= len(data) {
130+ continue
131+ }
132+ if strings.ContainsRune("ABCDHF", rune(data[i+2])) {
133+ i += 2
134+ continue
135+ }
136+ if i+3 < len(data) && (data[i+2] == '5' || data[i+2] == '6') && data[i+3] == '~' {
137+ i += 3
138+ continue
139+ }
140+ return false
141+ case 'O':
142+ // Alt+O is valid on its own; otherwise accept only application
143+ // cursor sequences.
144+ if i+2 >= len(data) {
145+ continue
146+ }
147+ if !strings.ContainsRune("ABCDHF", rune(data[i+2])) {
148+ return false
149+ }
150+ i += 2
151+ default:
152+ // The remaining form is Alt+one character. Its second byte may be
153+ // one of the explicit control-key aliases, but no other control
154+ // sequence or C1 byte is admitted. A second Escape must be checked
155+ // again as a possible sequence introducer; otherwise ESC ESC ]...
156+ // could hide an OSC response behind a valid Alt+Escape token.
157+ next := data[i+1]
158+ if next == 0x1b {
159+ continue
160+ }
161+ if next < 0x20 && next != '\r' && next != '\t' || next >= 0x80 && next <= 0x9f {
162+ return false
163+ }
164+ i++
165+ }
166+ }
167+ return true
168+}
A internal/usecase/client/ui_input_pump_test.go +150 -0
@@ -0,0 +1,150 @@
1+package client
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestUIInputPumpBatchFenceOrdering(t *testing.T) {
15+ for _, text := range []string{"hello", "\x1b", "\x1b[A"} {
16+ t.Run(text, func(t *testing.T) {
17+ ctx, cancel := context.WithCancel(context.Background())
18+ input := newTerminalInputPump(nil)
19+ consumer := input.claim()
20+ out := make(chan protocol.ClientMessage, 32)
21+ events := make(chan paletteGenerationEvent, 32)
22+ var palette atomic.Uint64
23+ done := make(chan struct{})
24+ go func() {
25+ defer close(done)
26+ (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: events, activeGeneration: &palette}).run()
27+ }()
28+ t.Cleanup(func() { cancel(); requireSignal(t, done, "scanner did not stop"); input.revoke(consumer) })
29+ req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte(text)}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}
30+ select {
31+ case input.automation <- req:
32+ case <-time.After(time.Second):
33+ t.Fatal("admission stalled")
34+ }
35+ require.True(t, <-req.admitted)
36+ var boundary paletteGenerationEvent
37+ select {
38+ case boundary = <-events:
39+ case <-time.After(time.Second):
40+ t.Fatal("missing FIFO boundary")
41+ }
42+ require.Equal(t, paletteEventUIBatchEnd, boundary.kind)
43+ var got string
44+ deadline := time.After(time.Second)
45+ for len(got) < len(text) {
46+ select {
47+ case message := <-out:
48+ in, ok := message.(protocol.Input)
49+ require.True(t, ok, "fence passed the local barrier")
50+ require.Equal(t, uint64(9), in.ActionID)
51+ got += string(in.Data)
52+ case <-deadline:
53+ t.Fatalf("automated batch stalled: got %q", got)
54+ }
55+ }
56+ require.Equal(t, text, got)
57+ // A physical read can queue while the action waits, but cannot interleave.
58+ require.True(t, input.enqueue(terminalReadResult{data: []byte("human")}, 1))
59+ close(boundary.batchApplied)
60+ select {
61+ case message := <-out:
62+ require.Equal(t, protocol.UIFence{ActionID: 9}, message)
63+ case <-time.After(time.Second):
64+ t.Fatal("missing fence")
65+ }
66+ require.True(t, <-req.dispatched)
67+ var human string
68+ deadline = time.After(time.Second)
69+ for len(human) < len("human") {
70+ select {
71+ case message := <-out:
72+ in, ok := message.(protocol.Input)
73+ require.True(t, ok)
74+ require.Zero(t, in.ActionID)
75+ human += string(in.Data)
76+ case <-deadline:
77+ t.Fatal("physical input stalled")
78+ }
79+ }
80+ require.Equal(t, "human", human)
81+ })
82+ }
83+}
84+
85+func TestUIInputPumpCancellationDiscardsAutomatedSuffix(t *testing.T) {
86+ ctx, cancel := context.WithCancel(context.Background())
87+ defer cancel()
88+ input := newTerminalInputPump(nil)
89+ consumer := input.claim()
90+ defer input.revoke(consumer)
91+ out := make(chan protocol.ClientMessage, 32)
92+ var palette atomic.Uint64
93+ done := make(chan struct{})
94+ go func() {
95+ defer close(done)
96+ (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: make(chan paletteGenerationEvent, 32), activeGeneration: &palette, afterInputTake: cancel}).run()
97+ }()
98+ req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte("suffix\x1b")}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}
99+ select {
100+ case input.automation <- req:
101+ case <-time.After(time.Second):
102+ t.Fatal("admission stalled")
103+ }
104+ requireSignal(t, done, "cancelled scanner did not stop")
105+ require.True(t, <-req.admitted)
106+ require.False(t, <-req.dispatched)
107+ require.Empty(t, out)
108+ input.mu.Lock()
109+ defer input.mu.Unlock()
110+ require.Empty(t, input.residual, "automated suffix cannot move to a replacement")
111+}
112+
113+func TestUIInputPumpRejectsHumanPartialBatch(t *testing.T) {
114+ for _, prefix := range []string{"\x1b]10;rgb:", "\x1b[?2031;", "\x1b[200~human", "\x1b]10;rgb:1111/2222/3333\x07"} {
115+ t.Run(prefix, func(t *testing.T) {
116+ ctx, cancel := context.WithCancel(context.Background())
117+ input := newTerminalInputPump(nil)
118+ consumer := input.claim()
119+ out := make(chan protocol.ClientMessage, 32)
120+ events := make(chan paletteGenerationEvent, 32)
121+ var palette atomic.Uint64
122+ done := make(chan struct{})
123+ go func() {
124+ defer close(done)
125+ (&stdinPump{ctx: ctx, cancel: cancel, input: input, consumer: consumer, uiGeneration: 7, out: out, clock: newAttachPaletteClock(), logger: slog.New(slog.DiscardHandler), paletteEvents: events, activeGeneration: &palette}).run()
126+ }()
127+ t.Cleanup(func() { cancel(); requireSignal(t, done, "scanner did not stop"); input.revoke(consumer) })
128+ require.True(t, input.enqueue(terminalReadResult{data: []byte(prefix)}, 1))
129+ // The unbuffered request runs at the next owner select, after the pending
130+ // physical read or before it; either order must reject without flushing it.
131+ req := terminalAutomationRequest{ctx: ctx, consumer: consumer, record: terminalReadResult{source: terminalInputAutomation, generation: 7, actionID: 9, endBatch: true, data: []byte("automated")}, admitted: make(chan bool, 1), dispatched: make(chan bool, 1)}
132+ select {
133+ case input.automation <- req:
134+ case <-time.After(time.Second):
135+ t.Fatal("admission stalled")
136+ }
137+ select {
138+ case admitted := <-req.admitted:
139+ require.False(t, admitted)
140+ case <-time.After(time.Second):
141+ t.Fatal("missing refusal")
142+ }
143+ select {
144+ case message := <-out:
145+ t.Fatalf("refusal flushed human input: %#v", message)
146+ default:
147+ }
148+ })
149+ }
150+}
A internal/usecase/client/ui_input_test.go +69 -0
@@ -0,0 +1,69 @@
1+package client
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestEncodeUIKeys(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ keys []string
12+ app bool
13+ want string
14+ invalid bool
15+ }{
16+ {name: "literal", keys: []string{"a", "Space", "Enter", "Tab", "Backspace", "Escape"}, want: "a \r\t\x7f\x1b"},
17+ {name: "navigation", keys: []string{"Up", "Down", "Right", "Left", "Home", "End", "PageUp", "PageDown"}, want: "\x1b[A\x1b[B\x1b[C\x1b[D\x1b[H\x1b[F\x1b[5~\x1b[6~"},
18+ {name: "application", keys: []string{"Up", "Home", "End"}, app: true, want: "\x1bOA\x1bOH\x1bOF"},
19+ {name: "modifiers", keys: []string{"Ctrl+A", "Ctrl+@", "Ctrl+_", "Alt+Space", "Alt+x"}, want: "\x01\x00\x1f\x1b \x1bx"},
20+ {name: "alt ASCII names", keys: []string{"Alt+Enter", "Alt+Tab", "Alt+Backspace", "Alt+Escape"}, want: "\x1b\r\x1b\t\x1b\x7f\x1b\x1b"},
21+ {name: "empty", invalid: true},
22+ {name: "case", keys: []string{"enter"}, invalid: true},
23+ {name: "combined", keys: []string{"Ctrl+Alt+A"}, invalid: true},
24+ {name: "modified navigation", keys: []string{"Alt+Up"}, invalid: true},
25+ {name: "raw", keys: []string{"\x1b[A"}, invalid: true},
26+ {name: "paste assembled", keys: []string{"Escape", "[", "2", "0", "0", "~"}, invalid: true},
27+ {name: "paste close assembled", keys: []string{"Alt+[", "2", "0", "1", "~"}, invalid: true},
28+ {name: "theme assembled", keys: []string{"Escape", "]", "1", "0", ";"}, invalid: true},
29+ {name: "doubled escape theme", keys: []string{"Escape", "Escape", "]", "1", "0", ";"}, invalid: true},
30+ {name: "doubled escape paste", keys: []string{"Escape", "Escape", "[", "2", "0", "0", "~"}, invalid: true},
31+ {name: "marker assembled", keys: []string{"Escape", "[", "?", "2", "0", "3", "1"}, invalid: true},
32+ {name: "scheme assembled", keys: []string{"Escape", "[", "?", "9", "9", "7"}, invalid: true},
33+ {name: "too many", keys: make([]string, 257), invalid: true},
34+ } {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, err := encodeUIKeys(tt.keys, tt.app)
37+ if (err != nil) != tt.invalid || string(got) != tt.want {
38+ t.Fatalf("got %q, %v; want %q invalid=%v", got, err, tt.want, tt.invalid)
39+ }
40+ })
41+ }
42+ // Every permitted unmodified Alt ASCII token is usable on its own.
43+ for b := byte(0x20); b <= 0x7e; b++ {
44+ got, err := encodeUIKeys([]string{"Alt+" + string(b)}, false)
45+ if err != nil || string(got) != "\x1b"+string(b) {
46+ t.Fatalf("Alt ASCII %q: %q %v", b, got, err)
47+ }
48+ }
49+}
50+
51+func TestEncodeUIText(t *testing.T) {
52+ for _, tt := range []struct {
53+ name, text string
54+ invalid bool
55+ }{
56+ {"printable", "hello 世界", false}, {"spaces", " ", false}, {"limit", strings.Repeat("x", uiMaxInputBytes), false},
57+ {"empty", "", true}, {"newline", "hello\n", true}, {"escape", "\x1b", true}, {"delete", "\x7f", true}, {"C1", "\u009b", true}, {"invalid UTF8", "\xff", true}, {"over limit", strings.Repeat("x", uiMaxInputBytes+1), true},
58+ } {
59+ t.Run(tt.name, func(t *testing.T) {
60+ got, err := encodeUIText(tt.text)
61+ if (err != nil) != tt.invalid {
62+ t.Fatalf("error=%v invalid=%v", err, tt.invalid)
63+ }
64+ if err == nil && string(got) != tt.text {
65+ t.Fatal("text changed")
66+ }
67+ })
68+ }
69+}
A internal/usecase/client/ui_publication_test.go +149 -0
@@ -0,0 +1,149 @@
1+package client_test
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/adapters/uiterm"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/usecase/client"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func TestRunnerPublishesCapturedContextAndMetadataWithoutTerminalBytes(t *testing.T) {
21+ ctx, cancel := context.WithCancel(context.Background())
22+ t.Cleanup(cancel)
23+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 6, Rows: 2}}, "public-handle")
24+ require.NoError(t, err)
25+ t.Cleanup(terminal.Close)
26+ transport := portsmocks.NewMockClientConnection(t)
27+ incoming := make(chan protocol.ServerMessage, 8)
28+ closed := make(chan struct{})
29+ var closeOnce sync.Once
30+ transport.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(closed) }); return nil }).Maybe()
31+ transport.EXPECT().Capabilities().Return(protocol.ConnectionCapabilities{}).Maybe()
32+ transport.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe()
33+ transport.EXPECT().LinkEvents().Return(nil).Maybe()
34+ transport.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
35+ select {
36+ case message := <-incoming:
37+ return message, nil
38+ case <-closed:
39+ return nil, io.EOF
40+ }
41+ }).Maybe()
42+ acks := make(chan protocol.Ack, 4)
43+ resets := make(chan struct{}, 4)
44+ transport.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error {
45+ switch message := message.(type) {
46+ case protocol.Ack:
47+ acks <- message
48+ case protocol.OutputResetRequest:
49+ resets <- struct{}{}
50+ }
51+ return nil
52+ }).Maybe()
53+ dialer := portsmocks.NewMockClientDialer(t)
54+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
55+ runner := client.NewRunner(client.Dependencies{Dialer: dialer, Terminal: terminal, Clock: realClock{}, DisableCapabilityProbe: true})
56+ done := make(chan error, 1)
57+ go func() {
58+ done <- runner.Run(ctx, client.AttachRequest{Intent: protocol.IntentAttach, SessionName: "work"})
59+ }()
60+ t.Cleanup(func() {
61+ cancel()
62+ select {
63+ case err := <-done:
64+ require.NoError(t, err)
65+ case <-time.After(2 * time.Second):
66+ t.Error("Runner did not release its owned attachment")
67+ }
68+ })
69+ view := protocol.ViewContext{
70+ Publication: 1,
71+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
72+ TabID: "tab-1", FocusedPaneID: "pane-1",
73+ }
74+ incoming <- protocol.Welcome{SessionID: "session", SessionName: "work"}
75+ initialView := view
76+ incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 6, Rows: 2}, Context: &initialView, Data: []byte("\x1b[Hhello")}
77+ initial := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 1 })
78+ require.Equal(t, "public-handle", initial.Context.AttachmentHandle)
79+ require.Equal(t, view.Route, initial.Context.Route)
80+ require.Equal(t, "hello", uiFirstCells(initial, 5))
81+ select {
82+ case ack := <-acks:
83+ require.Equal(t, protocol.Ack{Epoch: 1, State: 1}, ack)
84+ case <-time.After(time.Second):
85+ t.Fatal("initial applied output was not acknowledged")
86+ }
87+
88+ // A typed connection must not bypass semantic validation. The following
89+ // valid metadata is an ordered witness that the rejected frame was handled.
90+ incoming <- protocol.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 6, Rows: 2}, Data: []byte("bad")}
91+ select {
92+ case <-resets:
93+ case <-time.After(time.Second):
94+ t.Fatal("missing context did not request an authoritative reset")
95+ }
96+ view.Publication, view.FocusedPaneID = 2, "pane-2"
97+ incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view}
98+ focused := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 2 })
99+ require.Equal(t, initial.Cells, focused.Cells)
100+ require.Equal(t, initial.Cursor, focused.Cursor)
101+ require.Equal(t, domain.PaneStableID("pane-2"), focused.Context.FocusedPaneID)
102+ require.Equal(t, initial.Context.OutputState, focused.Context.OutputState)
103+ require.Greater(t, focused.Revision, initial.Revision)
104+ require.Equal(t, "public-handle", focused.Context.AttachmentHandle)
105+
106+ incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view} // duplicate
107+ incoming <- protocol.UIViewUpdate{Epoch: 9, State: 7, Context: protocol.ViewContext{Publication: 3, Route: view.Route, TabID: "tab-1", FocusedPaneID: "future"}}
108+ view.Publication = 3
109+ incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view}
110+ latest := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 3 })
111+ require.Equal(t, initial.Cells, latest.Cells)
112+ require.Empty(t, acks, "metadata and rejected output must not create ACK obligations")
113+ require.Empty(t, resets, "future dependencies must coalesce with the pending reset")
114+
115+ incoming <- protocol.Output{Epoch: 99, Size: domain.Size{Cols: 6, Rows: 2}, Data: []byte("\x1b[2;1H!")}
116+ sideEffect := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return len(snapshot.Cells) > 6 && snapshot.Cells[6].Text == "!" })
117+ require.Equal(t, latest.Context, sideEffect.Context, "side effects retain the committed view instead of inventing epoch 99")
118+
119+ view.Publication = 4
120+ view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"}
121+ incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 6, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hnew")}
122+ final := awaitUIPublication(t, terminal, func(snapshot ports.UISnapshot) bool { return snapshot.Context.ViewPublication == 4 })
123+ require.Equal(t, view.Route, final.Context.Route, "captured output identity wins over older route bookkeeping")
124+ require.Equal(t, "new", uiFirstCells(final, 3))
125+}
126+
127+func awaitUIPublication(t *testing.T, state ports.UIState, matches func(ports.UISnapshot) bool) ports.UISnapshot {
128+ t.Helper()
129+ deadline := time.NewTimer(2 * time.Second)
130+ defer deadline.Stop()
131+ for {
132+ if snapshot, err := state.Snapshot(); err == nil && matches(snapshot) {
133+ return snapshot
134+ }
135+ select {
136+ case <-state.Changes():
137+ case <-deadline.C:
138+ t.Fatal("expected UI publication was not committed")
139+ }
140+ }
141+}
142+
143+func uiFirstCells(snapshot ports.UISnapshot, count int) string {
144+ var text string
145+ for _, cell := range snapshot.Cells[:count] {
146+ text += cell.Text
147+ }
148+ return text
149+}
A internal/usecase/client/ui_remote_test.go +192 -0
@@ -0,0 +1,192 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/adapters/uiterm"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type uiTestPeer struct {
20+ connection *portsmocks.MockClientConnection
21+ incoming chan protocol.ServerMessage
22+ sent chan protocol.ClientMessage
23+}
24+
25+func newUITestPeer(t *testing.T, capabilities protocol.ConnectionCapabilities) *uiTestPeer {
26+ t.Helper()
27+ p := &uiTestPeer{connection: portsmocks.NewMockClientConnection(t), incoming: make(chan protocol.ServerMessage, 16), sent: make(chan protocol.ClientMessage, 256)}
28+ closed := make(chan struct{})
29+ var once sync.Once
30+ p.connection.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
31+ t.Cleanup(func() { _ = p.connection.Close() })
32+ p.connection.EXPECT().Capabilities().Return(capabilities).Maybe()
33+ p.connection.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe()
34+ p.connection.EXPECT().LinkEvents().Return(nil).Maybe()
35+ p.connection.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
36+ select {
37+ case message := <-p.incoming:
38+ return message, nil
39+ case <-closed:
40+ return nil, io.EOF
41+ }
42+ }).Maybe()
43+ p.connection.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error {
44+ select {
45+ case p.sent <- message:
46+ return nil
47+ case <-closed:
48+ return io.EOF
49+ }
50+ }).Maybe()
51+ return p
52+}
53+
54+func awaitUIClientMessage[T protocol.ClientMessage](t *testing.T, p *uiTestPeer) T {
55+ t.Helper()
56+ deadline := time.After(time.Second)
57+ for {
58+ select {
59+ case message := <-p.sent:
60+ if typed, ok := message.(T); ok {
61+ return typed
62+ }
63+ case <-deadline:
64+ t.Fatal("missing client message")
65+ var zero T
66+ return zero
67+ }
68+ }
69+}
70+
71+func TestUIRunnerRemoteAndParkedHandoffsRequireDestinationFull(t *testing.T) {
72+ ctx, cancel := context.WithCancel(context.Background())
73+ t.Cleanup(cancel)
74+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
75+ require.NoError(t, err)
76+ t.Cleanup(terminal.Close)
77+ ui := NewUI(terminal, systemClock{})
78+ source := newUITestPeer(t, protocol.ConnectionCapabilities{})
79+ destination := newUITestPeer(t, protocol.ConnectionCapabilities{PreferredOutputWindow: 1})
80+ picker := newUITestPeer(t, protocol.ConnectionCapabilities{})
81+ localDialer := portsmocks.NewMockClientDialer(t)
82+ 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)
85+ remoteDialer.EXPECT().Dial(mock.Anything).Return(destination.connection, nil).Once()
86+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "remote"}
87+ runner := NewRunner(Dependencies{Dialer: localDialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true, AttachHandoff: func(offer protocol.AttachTarget) (ports.ClientDialer, AttachRequest, error) {
88+ require.Equal(t, "isolated-fixture", offer.Endpoint)
89+ return remoteDialer, AttachRequest{Intent: protocol.IntentAttach, SessionName: "remote", Remote: true, Origin: protocol.RouteOriginRemote, OriginKey: "isolated-fixture", ExactTarget: &target}, nil
90+ }})
91+ done := make(chan error, 1)
92+ go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "local"}) }()
93+ t.Cleanup(func() {
94+ cancel()
95+ select {
96+ case err := <-done:
97+ require.NoError(t, err)
98+ case <-time.After(time.Second):
99+ t.Error("Runner did not stop")
100+ }
101+ })
102+ view := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "local"}}, TabID: "tab", FocusedPaneID: "pane"}
103+ source.incoming <- protocol.Welcome{SessionID: "local-session", SessionName: "local", CommittedIdentity: &view.Route}
104+ awaitUIClientMessage[protocol.Theme](t, source)
105+ awaitUIClientMessage[protocol.Theme](t, source)
106+ source.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[Hlocal")}
107+ attached := ports.UIStatusAttached
108+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}})
109+ require.NoError(t, err)
110+ results := make(chan ports.UIActionResult, 1)
111+ failures := make(chan error, 1)
112+ go func() {
113+ result, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Enter"}})
114+ if err != nil {
115+ failures <- err
116+ return
117+ }
118+ results <- result
119+ }()
120+ fence := awaitUIClientMessage[protocol.UIFence](t, source)
121+ source.incoming <- protocol.AttachTarget{Endpoint: "isolated-fixture", Session: "remote", ExactTarget: &target, CauseActionID: fence.ActionID}
122+ awaitUIClientMessage[protocol.Hello](t, destination)
123+ remoteView := view
124+ remoteView.Route.Target = target
125+ destination.incoming <- protocol.Welcome{SessionID: "remote-session", SessionName: "remote", CommittedIdentity: &remoteView.Route}
126+ awaitUIClientMessage[protocol.Theme](t, destination)
127+ select {
128+ case <-results:
129+ t.Fatal("Welcome completed the action before its viewport")
130+ case err := <-failures:
131+ t.Fatalf("handoff lost correlation: %v", err)
132+ default:
133+ }
134+ destination.incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &remoteView, Data: []byte("\x1b[2J\x1b[Hremote")}
135+ select {
136+ case result := <-results:
137+ require.Equal(t, ports.UIActionProcessed, result.Status)
138+ require.Equal(t, uint64(2), result.Context.Generation)
139+ require.Equal(t, target, result.Context.Route.Target)
140+ case err := <-failures:
141+ t.Fatal(err)
142+ case <-time.After(time.Second):
143+ t.Fatal("destination full did not complete handoff")
144+ }
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+}
A internal/usecase/client/ui_runner_test.go +186 -0
@@ -0,0 +1,186 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/adapters/uiterm"
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func TestUIRunnerAppliesFenceReceiptAfterMetadataPublication(t *testing.T) {
21+ testUIRunnerAction(t, false)
22+}
23+
24+func TestUIRunnerSamePeerActionWaitsForDestinationFull(t *testing.T) {
25+ testUIRunnerAction(t, true)
26+}
27+
28+func testUIRunnerAction(t *testing.T, handoff bool) {
29+ ctx, cancel := context.WithCancel(context.Background())
30+ t.Cleanup(cancel)
31+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
32+ require.NoError(t, err)
33+ t.Cleanup(terminal.Close)
34+ ui := NewUI(terminal, systemClock{})
35+ incoming := make(chan protocol.ServerMessage, 16)
36+ closed := make(chan struct{})
37+ var once sync.Once
38+ transport := portsmocks.NewMockClientConnection(t)
39+ transport.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
40+ transport.EXPECT().Capabilities().Return(protocol.ConnectionCapabilities{}).Maybe()
41+ transport.EXPECT().LinkState().Return(ports.LinkStateConnected).Maybe()
42+ transport.EXPECT().LinkEvents().Return(nil).Maybe()
43+ transport.EXPECT().ReceiveServer().RunAndReturn(func() (protocol.ServerMessage, error) {
44+ select {
45+ case message := <-incoming:
46+ return message, nil
47+ case <-closed:
48+ return nil, io.EOF
49+ }
50+ }).Maybe()
51+ view := protocol.ViewContext{Publication: 1, Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}}, TabID: "tab", FocusedPaneID: "pane"}
52+ var themeCount atomic.Int32
53+ sentInput := make(chan protocol.Input, 16)
54+ fences := make(chan protocol.UIFence, 1)
55+ switches := make(chan protocol.SamePeerSwitchRequest, 1)
56+ routeSnapshots := make(chan protocol.RecentRouteSnapshot, 8)
57+ transport.EXPECT().SendClient(mock.Anything).RunAndReturn(func(message protocol.ClientMessage) error {
58+ switch message := message.(type) {
59+ case protocol.Theme:
60+ if themeCount.Add(1) == 2 {
61+ initial := view
62+ incoming <- protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &initial, Data: []byte("\x1b[Hready")}
63+ }
64+ case protocol.Input:
65+ sentInput <- message
66+ case protocol.UIFence:
67+ fences <- message
68+ case protocol.SamePeerSwitchRequest:
69+ switches <- message
70+ case protocol.RecentRouteSnapshot:
71+ routeSnapshots <- message
72+ }
73+ return nil
74+ }).Maybe()
75+ dialer := portsmocks.NewMockClientDialer(t)
76+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
77+ runner := NewRunner(Dependencies{Dialer: dialer, Terminal: terminal, Clock: systemClock{}, UI: ui, DisableCapabilityProbe: true})
78+ done := make(chan error, 1)
79+ go func() { done <- runner.Run(ctx, AttachRequest{Intent: protocol.IntentAttach, SessionName: "fixture"}) }()
80+ t.Cleanup(func() {
81+ cancel()
82+ select {
83+ case err := <-done:
84+ require.NoError(t, err)
85+ case <-time.After(time.Second):
86+ t.Error("Runner did not detach")
87+ }
88+ })
89+ incoming <- protocol.Welcome{SessionID: "session", SessionName: "fixture", CommittedIdentity: &view.Route}
90+ attached := ports.UIStatusAttached
91+ _, err = ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}})
92+ require.NoError(t, err)
93+ result := make(chan ports.UIActionResult, 1)
94+ failed := make(chan error, 1)
95+ go func() {
96+ action, err := ui.Action(ctx, ports.UIActionRequest{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}})
97+ if err != nil {
98+ failed <- err
99+ return
100+ }
101+ result <- action
102+ }()
103+ var fence protocol.UIFence
104+ select {
105+ case fence = <-fences:
106+ case err := <-failed:
107+ t.Fatalf("action failed: %v", err)
108+ case <-time.After(time.Second):
109+ t.Fatal("missing labeled fence")
110+ }
111+ var input protocol.Input
112+ select {
113+ case input = <-sentInput:
114+ case <-time.After(time.Second):
115+ t.Fatal("input was not dispatched")
116+ }
117+ require.Equal(t, []byte("\x1b"), input.Data)
118+ require.Equal(t, fence.ActionID, input.ActionID)
119+ select {
120+ case <-result:
121+ t.Fatal("input dispatch alone completed action")
122+ default:
123+ }
124+ view.Publication = 2
125+ if handoff {
126+ drainRouteSnapshots:
127+ for {
128+ select {
129+ case <-routeSnapshots:
130+ default:
131+ break drainRouteSnapshots
132+ }
133+ }
134+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "destination"}
135+ incoming <- protocol.AttachTarget{SamePeer: true, ExactTarget: &target, CauseActionID: fence.ActionID}
136+ select {
137+ case request := <-switches:
138+ require.Equal(t, target, request.Target)
139+ case <-time.After(time.Second):
140+ t.Fatal("missing existing same-peer switch request")
141+ }
142+ incoming <- protocol.CommittedRouteIdentity{Target: target}
143+ deadline := time.After(time.Second)
144+ committed := false
145+ for !committed {
146+ select {
147+ case snapshot := <-routeSnapshots:
148+ committed = snapshot.ActiveEntry.Target == target
149+ case <-deadline:
150+ t.Fatal("identity commit was not handled")
151+ }
152+ }
153+ select {
154+ case <-result:
155+ t.Fatal("identity alone completed the action")
156+ case err := <-failed:
157+ t.Fatalf("handoff failed: %v", err)
158+ default:
159+ }
160+ view.Route.Target = target
161+ incoming <- protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 8, Rows: 2}, Context: &view, Data: []byte("\x1b[2J\x1b[Hready")}
162+ } else {
163+ incoming <- protocol.UIViewUpdate{Epoch: 1, State: 1, Context: view}
164+ incoming <- protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed}
165+ }
166+ var action ports.UIActionResult
167+ select {
168+ case action = <-result:
169+ case err := <-failed:
170+ t.Fatalf("action failed: %v", err)
171+ case <-time.After(time.Second):
172+ t.Fatal("applied receipt did not complete action")
173+ }
174+ require.True(t, action.Accepted)
175+ require.Equal(t, ports.UIActionProcessed, action.Status)
176+ require.Equal(t, uint64(2), action.Context.ViewPublication)
177+ require.Equal(t, ui.Handle(), action.Context.AttachmentHandle)
178+ if handoff {
179+ require.Equal(t, uint64(2), action.Context.Generation)
180+ require.Equal(t, "destination", action.Context.Route.Target.SessionName)
181+ }
182+ text := "ready"
183+ matched, err := ui.Wait(ctx, ports.UIWaitRequest{Attachment: ui.Handle(), AfterAction: action.ActionID, Expect: ports.UIExpect{TextContains: &text, Status: &attached}})
184+ require.NoError(t, err)
185+ require.GreaterOrEqual(t, matched.Revision, action.Revision)
186+}
A internal/usecase/client/ui_test.go +216 -0
@@ -0,0 +1,216 @@
1+package client
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/adapters/uiterm"
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func newUITestService(t *testing.T) (*UI, *uiterm.Terminal, *attachPaletteClock, context.Context) {
16+ t.Helper()
17+ ctx, cancel := context.WithCancel(context.Background())
18+ t.Cleanup(cancel)
19+ terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
20+ require.NoError(t, err)
21+ t.Cleanup(terminal.Close)
22+ clock := newAttachPaletteClock()
23+ u := NewUI(terminal, clock)
24+ input := newTerminalInputPump(nil)
25+ consumer := input.claim()
26+ generation := u.bindForeground(ctx, input, consumer)
27+ view := ports.UIContext{AttachmentHandle: u.Handle(), Generation: generation, Status: ports.UIStatusAttached, OutputEpoch: 1, OutputState: 1, ViewPublication: 1, TabID: "tab", FocusedPaneID: "pane"}
28+ view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}
29+ terminal.BeginOutput(view)
30+ _, err = terminal.Write([]byte("ready"))
31+ require.NoError(t, err)
32+ require.NoError(t, terminal.Flush())
33+ terminal.EndOutput(true)
34+ u.published(generation)
35+ return u, terminal, clock, ctx
36+}
37+
38+func TestUIWaitConjunctiveCurrentSnapshot(t *testing.T) {
39+ u, terminal, _, ctx := newUITestService(t)
40+ snapshot, err := terminal.Snapshot()
41+ require.NoError(t, err)
42+ text := "ready"
43+ status := ports.UIStatusAttached
44+ result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text, Session: &snapshot.Context.Route.Target, Focus: &ports.UIFocus{TabID: "tab", PaneID: "pane"}, Status: &status}})
45+ require.NoError(t, err)
46+ require.Equal(t, snapshot.Revision, result.Revision)
47+ require.Equal(t, "ready \n ", uiSnapshotText(snapshot))
48+ wrong := "wrong"
49+ require.False(t, uiMatches(snapshot, ports.UIExpect{TextContains: &text, Focus: &ports.UIFocus{TabID: "tab", PaneID: domain.PaneStableID(wrong)}}))
50+}
51+
52+func TestUIActionTimeoutRetainsExactReceiptBoundary(t *testing.T) {
53+ u, terminal, clock, ctx := newUITestService(t)
54+ errors := make(chan error, 1)
55+ go func() {
56+ _, err := u.Action(ctx, ports.UIActionRequest{Attachment: u.Handle(), Generation: 1, Text: "x"})
57+ errors <- err
58+ }()
59+ var batch terminalAutomationRequest
60+ select {
61+ case batch = <-u.input.automation:
62+ case <-time.After(time.Second):
63+ t.Fatal("action did not reach owner")
64+ }
65+ requestTimer := requireTimer(t, clock.timers, "missing request deadline")
66+ require.True(t, batch.owner.accept(batch.record.actionID, batch.record.generation))
67+ batch.admitted <- true
68+ batch.dispatched <- true
69+ requireTimer(t, clock.timers, "missing retained action expiry")
70+ requestTimer.fire()
71+ var actionErr *ports.UIError
72+ select {
73+ case err := <-errors:
74+ require.ErrorAs(t, err, &actionErr)
75+ case <-time.After(time.Second):
76+ t.Fatal("request deadline stalled")
77+ }
78+ require.Equal(t, ports.UIErrTimeout, actionErr.Code)
79+ require.True(t, actionErr.Accepted)
80+ require.Equal(t, batch.record.actionID, actionErr.ActionID)
81+ // A receipt for another publication cannot use an earlier matching screen.
82+ u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
83+ u.mu.Lock()
84+ require.Equal(t, ports.UIActionPending, u.records[actionErr.ActionID].Status)
85+ u.mu.Unlock()
86+ snapshot, err := terminal.Snapshot()
87+ require.NoError(t, err)
88+ view := snapshot.Context
89+ view.ViewPublication = 2
90+ require.NoError(t, terminal.PublishContext(view))
91+ u.published(1)
92+ boundary, err := terminal.Snapshot()
93+ require.NoError(t, err)
94+ // Later presentation-only publication must not shift the action lower bound.
95+ view.Status = ports.UIStatusReconnecting
96+ require.NoError(t, terminal.PublishContext(view))
97+ u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
98+ u.mu.Lock()
99+ record := u.records[actionErr.ActionID]
100+ u.mu.Unlock()
101+ require.Equal(t, ports.UIActionProcessed, record.Status)
102+ require.Equal(t, boundary.Revision, record.Revision)
103+ text := "ready"
104+ result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: record.ActionID, Expect: ports.UIExpect{TextContains: &text}})
105+ require.NoError(t, err)
106+ require.GreaterOrEqual(t, result.Revision, record.Revision)
107+}
108+
109+func TestUIWaitBroadcastAndAttachmentQuota(t *testing.T) {
110+ u, terminal, clock, ctx := newUITestService(t)
111+ observeCtx, cancel := context.WithCancel(ctx)
112+ done := make(chan struct{})
113+ go func() { defer close(done); u.Observe(observeCtx) }()
114+ t.Cleanup(func() { cancel(); requireSignal(t, done, "observation worker did not stop") })
115+ status := ports.UIStatusReconnecting
116+ results := make(chan error, 4)
117+ request := ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{Status: &status}}
118+ for range 4 {
119+ go func() { _, err := u.Wait(ctx, request); results <- err }()
120+ requireTimer(t, clock.timers, "wait was not admitted")
121+ }
122+ _, err := u.Wait(ctx, request)
123+ var uiErr *ports.UIError
124+ require.ErrorAs(t, err, &uiErr)
125+ require.Equal(t, ports.UIErrBusy, uiErr.Code)
126+ snapshot, err := terminal.Snapshot()
127+ require.NoError(t, err)
128+ snapshot.Context.Status = status
129+ require.NoError(t, terminal.PublishContext(snapshot.Context))
130+ for range 4 {
131+ select {
132+ case err := <-results:
133+ require.NoError(t, err)
134+ case <-time.After(time.Second):
135+ t.Fatal("coalesced publication did not wake every waiter")
136+ }
137+ }
138+}
139+
140+func TestUIWaitForSnapshotUsesObservationBroadcast(t *testing.T) {
141+ u, terminal, _, ctx := newUITestService(t)
142+ observeCtx, cancel := context.WithCancel(ctx)
143+ observed := make(chan struct{})
144+ go func() {
145+ u.Observe(observeCtx)
146+ close(observed)
147+ }()
148+ defer func() {
149+ cancel()
150+ select {
151+ case <-observed:
152+ case <-time.After(time.Second):
153+ t.Fatal("observation worker did not stop")
154+ }
155+ }()
156+
157+ status := ports.UIStatusReconnecting
158+ result := make(chan ports.UISnapshot, 1)
159+ go func() {
160+ snapshot, err := u.WaitForSnapshot(ctx, func(snapshot ports.UISnapshot) bool {
161+ return snapshot.Context.Status == status
162+ })
163+ if err != nil {
164+ return
165+ }
166+ result <- snapshot
167+ }()
168+ current, err := terminal.Snapshot()
169+ require.NoError(t, err)
170+ current.Context.Status = status
171+ require.NoError(t, terminal.PublishContext(current.Context))
172+ select {
173+ case snapshot := <-result:
174+ require.Equal(t, status, snapshot.Context.Status)
175+ case <-time.After(time.Second):
176+ t.Fatal("UI observation broadcast did not wake snapshot waiter")
177+ }
178+}
179+
180+func TestUIActionHistoryEvictsOnlyOnAcceptance(t *testing.T) {
181+ u, _, _, _ := newUITestService(t)
182+ for id := uint64(1); id <= uiActionHistory; id++ {
183+ u.pending = id
184+ u.reservedContext = ports.UIContext{Generation: 1}
185+ require.True(t, u.accept(id, 1))
186+ }
187+ u.pending = 65
188+ require.False(t, u.accept(65, 2))
189+ require.Len(t, u.records, uiActionHistory)
190+ require.Contains(t, u.records, uint64(1), "a refused generation cannot evict a retained result")
191+ require.True(t, u.accept(65, 1))
192+ require.Len(t, u.records, uiActionHistory)
193+ require.NotContains(t, u.records, uint64(1))
194+}
195+
196+func TestUIWaitFakeDeadlineAndExpiredAction(t *testing.T) {
197+ u, _, clock, ctx := newUITestService(t)
198+ text := "not present"
199+ errors := make(chan error, 1)
200+ go func() {
201+ _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text}})
202+ errors <- err
203+ }()
204+ requireTimer(t, clock.timers, "missing wait timer").fire()
205+ var uiErr *ports.UIError
206+ select {
207+ case err := <-errors:
208+ require.ErrorAs(t, err, &uiErr)
209+ case <-time.After(time.Second):
210+ t.Fatal("wait deadline stalled")
211+ }
212+ require.Equal(t, ports.UIErrTimeout, uiErr.Code)
213+ _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: 99, Expect: ports.UIExpect{TextContains: &text}})
214+ require.ErrorAs(t, err, &uiErr)
215+ require.Equal(t, ports.UIErrActionExpired, uiErr.Code)
216+}
M internal/usecase/client/wire_external_test_helpers_test.go +9 -0
@@ -1,11 +1,20 @@
1 package client_test1 package client_test
2 2
3 import (3 import (
4+ "github.com/bnema/vev/internal/domain"
4 "github.com/bnema/vev/internal/protocol"5 "github.com/bnema/vev/internal/protocol"
5 "github.com/bnema/vev/internal/protocol/wire"6 "github.com/bnema/vev/internal/protocol/wire"
6 )7 )
7 8
8 func mustMarshalOutput(m protocol.Output) []byte {9 func mustMarshalOutput(m protocol.Output) []byte {
10+ // Replay-only fixtures still model valid v41 semantic publications.
11+ if m.New != 0 && m.Context == nil {
12+ m.Context = &protocol.ViewContext{
13+ Publication: m.Epoch<<32 | m.New,
14+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
15+ TabID: "tab-1", FocusedPaneID: "pane-1",
16+ }
17+ }
9 payload, err := wire.MarshalOutput(m)18 payload, err := wire.MarshalOutput(m)
10 if err != nil {19 if err != nil {
11 panic(err)20 panic(err)
M internal/usecase/client/wire_test_helpers_test.go +6 -0
@@ -6,6 +6,12 @@ import (
6 )6 )
7 7
8 func mustMarshalOutput(m protocol.Output) []byte {8 func mustMarshalOutput(m protocol.Output) []byte {
9+ // Replay-only fixtures still model valid v41 semantic publications. Tests
10+ // of rejected/missing context bypass this convenience constructor.
11+ if m.New != 0 && m.Context == nil {
12+ context := testOutputView(m.Epoch<<32 | m.New)
13+ m.Context = &context
14+ }
9 payload, err := wire.MarshalOutput(m)15 payload, err := wire.MarshalOutput(m)
10 if err != nil {16 if err != nil {
11 panic(err)17 panic(err)
M internal/usecase/daemon/attachment_effect_gate.go +21 -1
@@ -86,7 +86,10 @@ type attachmentEffect struct {
86 lifecycle *attachmentLifecycle86 lifecycle *attachmentLifecycle
87 actionDaemon *Daemon87 actionDaemon *Daemon
88 action string88 action string
89- ended atomic.Bool89+ // uiActionID belongs to this admitted input, not the attachment lifecycle.
90+ // Set before routing and copy only into its exact delayed continuation.
91+ uiActionID uint64
92+ ended atomic.Bool
90 }93 }
91 94
92 func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) {95 func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) {
@@ -198,6 +201,23 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error {
198 if !t.current() || !t.ac.transportSnapshotCurrent(t.transport) || !t.beginTransportSend(t.transport) {201 if !t.current() || !t.ac.transportSnapshotCurrent(t.transport) || !t.beginTransportSend(t.transport) {
199 return errAttachmentTransition202 return errAttachmentTransition
200 }203 }
204+ // Bind navigation at its single admitted send boundary. Palette catalogue
205+ // values remain reusable; a later action must never inherit an earlier
206+ // template's cause or consult a mutable attachment-wide "latest action".
207+ switch navigation := message.(type) {
208+ case protocol.AttachTarget:
209+ navigation.CauseActionID = t.uiActionID
210+ message = navigation
211+ case protocol.NavigationDirective:
212+ navigation.CauseActionID = t.uiActionID
213+ message = navigation
214+ case protocol.RouteNavigationAction:
215+ navigation.CauseActionID = t.uiActionID
216+ message = navigation
217+ case protocol.RouteCreateSessionAction:
218+ navigation.CauseActionID = t.uiActionID
219+ message = navigation
220+ }
201 err := t.transport.transport.SendServer(message)221 err := t.transport.transport.SendServer(message)
202 if err != nil {222 if err != nil {
203 t.reportTransportFailure(t.transport)223 t.reportTransportFailure(t.transport)
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
@@ -765,7 +765,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
765 defer release2()765 defer release2()
766 defer release3()766 defer release3()
767 d, source, ac, _ := newManualSessionWithPTYs(t, p1)767 d, source, ac, _ := newManualSessionWithPTYs(t, p1)
768- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{768+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: newTestLifecycle(t)}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
769 newTab(p2, domain.Size{Cols: 80, Rows: 23}),769 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
770 newTab(p3, domain.Size{Cols: 80, Rows: 23}),770 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
771 }}771 }}
M internal/usecase/daemon/attachment_output.go +84 -9
@@ -32,6 +32,8 @@ type attachmentOutput struct {
32 maxOutstandingAtomic atomic.Uint6432 maxOutstandingAtomic atomic.Uint64
33 forceSnapshot bool33 forceSnapshot bool
34 initialized bool34 initialized bool
35+ viewPublication uint64
36+ lastViewContext protocol.ViewContext
35 lastCursor cursorOut37 lastCursor cursorOut
36 lastRoutePosition protocol.RoutePosition38 lastRoutePosition protocol.RoutePosition
37 graphicsOutput *graphicsOutputState39 graphicsOutput *graphicsOutputState
@@ -138,6 +140,9 @@ func (o *attachmentOutput) prepareFrame(d *Daemon, state *capturedRenderState, f
138 if err != nil {140 if err != nil {
139 return nil, err141 return nil, err
140 }142 }
143+ context := state.viewContext()
144+ ansi.context = &context
145+ ansi.viewRevision = state.view.revision
141 cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)146 cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
142 graphicsReset := reset || o.forceSnapshot || !o.initialized147 graphicsReset := reset || o.forceSnapshot || !o.initialized
143 graphicsBudget := max(protocol.MaxOutputDataLen-len(ansi.data)-len(cursor.data), 0)148 graphicsBudget := max(protocol.MaxOutputDataLen-len(ansi.data)-len(cursor.data), 0)
@@ -172,6 +177,18 @@ func (p *preparedAttachmentOutput) send(echoAck uint64, send func(protocol.Outpu
172 return nil177 return nil
173 }178 }
174 179
180+func (p *preparedAttachmentOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error {
181+ if p == nil || p.ansi == nil {
182+ return nil
183+ }
184+ if err := p.ansi.publishNoBytes(confirm, send); err != nil {
185+ p.abortGraphics()
186+ return err
187+ }
188+ p.commitIfSent()
189+ return nil
190+}
191+
175 func (p *preparedAttachmentOutput) commitNoSend() {192 func (p *preparedAttachmentOutput) commitNoSend() {
176 if p == nil || p.ansi == nil {193 if p == nil || p.ansi == nil {
177 return194 return
@@ -244,11 +261,15 @@ type preparedOutput struct {
244 generation uint64261 generation uint64
245 connectionGeneration uint64262 connectionGeneration uint64
246 viewRevision uint64263 viewRevision uint64
247- size domain.Size264+ context *protocol.ViewContext
248- data []byte265+ // boundary is the exact accepted semantic publication, not mutable stream
249- reset bool266+ // state that a concurrent view rebase could replace before receipt send.
250- attempted bool267+ boundary protocol.UIReceipt
251- sent bool268+ size domain.Size
269+ data []byte
270+ reset bool
271+ attempted bool
272+ sent bool
252 }273 }
253 274
254 func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {275 func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
@@ -304,7 +325,16 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Ou
304 if p.reset {325 if p.reset {
305 base = 0326 base = 0
306 }327 }
307- out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)328+ var context *protocol.ViewContext
329+ if p.context != nil {
330+ if p.stream.viewPublication == ^uint64(0) {
331+ return errors.New("view publication number exhausted")
332+ }
333+ candidate := *p.context
334+ candidate.Publication = p.stream.viewPublication + 1
335+ context = &candidate
336+ }
337+ out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset, context)
308 if err != nil {338 if err != nil {
309 p.stream.forceSnapshot = true339 p.stream.forceSnapshot = true
310 return err340 return err
@@ -314,6 +344,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Ou
314 return err344 return err
315 }345 }
316 p.commit()346 p.commit()
347+ if context != nil {
348+ p.stream.viewPublication = context.Publication
349+ p.stream.lastViewContext = *context
350+ p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.next, ViewPublication: context.Publication, Outcome: protocol.UIReceiptProcessed}
351+ }
317 p.stream.next = p.next352 p.stream.next = p.next
318 p.stream.publishOutstanding()353 p.stream.publishOutstanding()
319 p.sent = true354 p.sent = true
@@ -334,6 +369,46 @@ func (p *preparedOutput) commitNoSend() {
334 p.sent = true369 p.sent = true
335 }370 }
336 371
372+// publishNoBytes commits a composition without advancing the terminal replay
373+// chain. A fence can request a fresh semantic boundary even for an unchanged
374+// view. Callers retain the ordinary attachment send/effect ownership.
375+func (p *preparedOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error {
376+ if p == nil || p.stream == nil || p.attempted {
377+ return nil
378+ }
379+ p.stream.lockView()
380+ defer p.stream.unlockView()
381+ p.attempted = true
382+ if !p.currentLocked() || len(p.data) != 0 {
383+ return nil
384+ }
385+ if p.context != nil {
386+ candidate := *p.context
387+ candidate.Publication = p.stream.viewPublication
388+ if confirm || candidate != p.stream.lastViewContext {
389+ if p.stream.next == 0 {
390+ return errors.New("view update requires committed output")
391+ }
392+ if candidate.Publication == ^uint64(0) {
393+ return errors.New("view publication number exhausted")
394+ }
395+ candidate.Publication++
396+ if err := candidate.Validate(); err != nil {
397+ return err
398+ }
399+ if err := send(protocol.UIViewUpdate{Epoch: p.epoch, State: p.stream.next, Context: candidate}); err != nil {
400+ return err
401+ }
402+ p.stream.viewPublication = candidate.Publication
403+ p.stream.lastViewContext = candidate
404+ p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.stream.next, ViewPublication: candidate.Publication, Outcome: protocol.UIReceiptProcessed}
405+ }
406+ }
407+ p.commit()
408+ p.sent = true
409+ return nil
410+}
411+
337 func (p *preparedOutput) commit() {412 func (p *preparedOutput) commit() {
338 p.draw.Commit()413 p.draw.Commit()
339 p.stream.forceSnapshot = false414 p.stream.forceSnapshot = false
@@ -379,10 +454,10 @@ func (p *preparedOutput) currentLocked() bool {
379 (p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration)454 (p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration)
380 }455 }
381 456
382-func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (protocol.Output, error) {457+func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool, context *protocol.ViewContext) (protocol.Output, error) {
383 output := protocol.Output{458 output := protocol.Output{
384 Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,459 Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,
385- Size: size, Full: full, Data: data,460+ Size: size, Full: full, Data: data, Context: context,
386 }461 }
387 if err := protocol.ValidateOutput(output); err != nil {462 if err := protocol.ValidateOutput(output); err != nil {
388 return protocol.Output{}, err463 return protocol.Output{}, err
@@ -408,7 +483,7 @@ func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (protoc
408 if s != nil && s.attachment != nil {483 if s != nil && s.attachment != nil {
409 size = s.attachment.sizeSnapshot()484 size = s.attachment.sizeSnapshot()
410 }485 }
411- return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)486+ return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false, nil)
412 }487 }
413 488
414 func (s *attachmentOutput) ack(epoch, state uint64) bool {489 func (s *attachmentOutput) ack(epoch, state uint64) bool {
M internal/usecase/daemon/attachment_output_test.go +39 -22
@@ -85,7 +85,7 @@ func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
85 stream := newOutputStateStream(1)85 stream := newOutputStateStream(1)
86 frame := renderer.NewFrame(3, 1)86 frame := renderer.NewFrame(3, 1)
87 fillOutputStateRows(frame, []string{"abc"})87 fillOutputStateRows(frame, []string{"abc"})
88- prepared, err := stream.prepare(frame, nil, true)88+ prepared, err := stream.prepareTestOutput(frame, nil, true)
89 require.NoError(t, err)89 require.NoError(t, err)
90 90
91 entered := make(chan struct{})91 entered := make(chan struct{})
@@ -98,7 +98,7 @@ func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
98 return nil98 return nil
99 }))99 }))
100 }()100 }()
101- <-entered101+ awaitTestCompletion(t, entered, "output send was not admitted")
102 102
103 probed := make(chan struct{})103 probed := make(chan struct{})
104 go func() {104 go func() {
@@ -131,7 +131,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
131 131
132 first := newAttachment()132 first := newAttachment()
133 second := newAttachment()133 second := newAttachment()
134- firstPrepared, err := first.prepare(frame, nil, true)134+ firstPrepared, err := first.prepareTestOutput(frame, nil, true)
135 require.NoError(t, err)135 require.NoError(t, err)
136 var firstFrame wire.Frame136 var firstFrame wire.Frame
137 require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Frame) error {137 require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -145,7 +145,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
145 require.True(t, firstOutput.Full)145 require.True(t, firstOutput.Full)
146 first.ack(firstOutput.Epoch, firstOutput.New)146 first.ack(firstOutput.Epoch, firstOutput.New)
147 147
148- secondPrepared, err := second.prepare(frame, nil, true)148+ secondPrepared, err := second.prepareTestOutput(frame, nil, true)
149 require.NoError(t, err)149 require.NoError(t, err)
150 require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))150 require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
151 require.Equal(t, uint64(1), second.epoch)151 require.Equal(t, uint64(1), second.epoch)
@@ -154,7 +154,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
154 154
155 changed := frame.Clone()155 changed := frame.Clone()
156 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})156 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
157- pending, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)157+ pending, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
158 require.NoError(t, err)158 require.NoError(t, err)
159 first.rebase()159 first.rebase()
160 require.Equal(t, uint64(2), first.epoch)160 require.Equal(t, uint64(2), first.epoch)
@@ -170,7 +170,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
170 first.ack(1, 1)170 first.ack(1, 1)
171 require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")171 require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")
172 172
173- reset, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)173+ reset, err := first.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
174 require.NoError(t, err)174 require.NoError(t, err)
175 var resetFrame wire.Frame175 var resetFrame wire.Frame
176 require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Frame) error {176 require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -203,12 +203,12 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
203 stream.attachment = ac203 stream.attachment = ac
204 frame := renderer.NewFrame(3, 1)204 frame := renderer.NewFrame(3, 1)
205 fillOutputStateRows(frame, []string{"abc"})205 fillOutputStateRows(frame, []string{"abc"})
206- initial, err := stream.prepare(frame, nil, true)206+ initial, err := stream.prepareTestOutput(frame, nil, true)
207 require.NoError(t, err)207 require.NoError(t, err)
208 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))208 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
209 changed := frame.Clone()209 changed := frame.Clone()
210 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})210 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
211- pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)211+ pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
212 require.NoError(t, err)212 require.NoError(t, err)
213 tt.change(ac)213 tt.change(ac)
214 var sent bool214 var sent bool
@@ -219,7 +219,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
219 require.False(t, sent)219 require.False(t, sent)
220 require.Equal(t, uint64(1), stream.next)220 require.Equal(t, uint64(1), stream.next)
221 221
222- retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)222+ retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
223 require.NoError(t, err)223 require.NoError(t, err)
224 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))224 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
225 require.Equal(t, uint64(2), stream.next)225 require.Equal(t, uint64(2), stream.next)
@@ -257,14 +257,14 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T)
257 stream := newOutputStateStream()257 stream := newOutputStateStream()
258 initial := renderer.NewFrame(3, 1)258 initial := renderer.NewFrame(3, 1)
259 fillOutputStateRows(initial, []string{"abc"})259 fillOutputStateRows(initial, []string{"abc"})
260- first, err := stream.prepare(initial, nil, true)260+ first, err := stream.prepareTestOutput(initial, nil, true)
261 require.NoError(t, err)261 require.NoError(t, err)
262 require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))262 require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
263 require.Equal(t, uint64(1), stream.next)263 require.Equal(t, uint64(1), stream.next)
264 264
265 changed := initial.Clone()265 changed := initial.Clone()
266 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})266 changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
267- pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)267+ pending, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
268 require.NoError(t, err)268 require.NoError(t, err)
269 probe, err := stream.renderer.Prepare(initial, nil, false)269 probe, err := stream.renderer.Prepare(initial, nil, false)
270 require.NoError(t, err)270 require.NoError(t, err)
@@ -278,7 +278,7 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T)
278 require.NoError(t, err)278 require.NoError(t, err)
279 require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")279 require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
280 280
281- retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)281+ retry, err := stream.prepareTestOutput(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
282 require.NoError(t, err)282 require.NoError(t, err)
283 var sent wire.Frame283 var sent wire.Frame
284 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Frame) error {284 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -304,7 +304,7 @@ func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
304 stream := newOutputStateStream()304 stream := newOutputStateStream()
305 frame := renderer.NewFrame(3, 1)305 frame := renderer.NewFrame(3, 1)
306 fillOutputStateRows(frame, []string{"abc"})306 fillOutputStateRows(frame, []string{"abc"})
307- initial, err := stream.prepare(frame, nil, true)307+ initial, err := stream.prepareTestOutput(frame, nil, true)
308 require.NoError(t, err)308 require.NoError(t, err)
309 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))309 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
310 310
@@ -376,9 +376,11 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck
376 if reset {376 if reset {
377 base = 0377 base = 0
378 }378 }
379- output, err := serverOutputState(data, base, stream.next, echoAck)379+ context := publicationTestContext()
380- if err != nil {380+ context.Publication = stream.next
381- panic(err)381+ output := protocol.Output{
382+ Epoch: 1, Base: base, New: stream.next, Echo: echoAck,
383+ Size: domain.Size{Cols: 1, Rows: 1}, Full: reset, Data: data, Context: &context,
382 }384 }
383 frame, err := testServerFrame(output)385 frame, err := testServerFrame(output)
384 if err != nil {386 if err != nil {
@@ -387,9 +389,20 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck
387 return frame389 return frame
388 }390 }
389 391
392+// prepareTestOutput gives replay-only fixtures explicit semantic ownership;
393+// production obtains this from capturedRenderState in prepareFrame.
394+func (s *attachmentOutput) prepareTestOutput(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
395+ prepared, err := s.prepare(frame, damage, reset)
396+ if err == nil {
397+ context := publicationTestContext()
398+ prepared.context = &context
399+ }
400+ return prepared, err
401+}
402+
390 func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Frame, bool, error) {403 func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Frame, bool, error) {
391 t.Helper()404 t.Helper()
392- prepared, err := stream.prepare(frame, damage, reset)405+ prepared, err := stream.prepareTestOutput(frame, damage, reset)
393 if err != nil || len(prepared.data) == 0 {406 if err != nil || len(prepared.data) == 0 {
394 if prepared != nil {407 if prepared != nil {
395 prepared.commitNoSend()408 prepared.commitNoSend()
@@ -443,11 +456,15 @@ func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *test
443 require.NoError(t, err)456 require.NoError(t, err)
444 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})457 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
445 require.NoError(t, err)458 require.NoError(t, err)
446- state := &capturedRenderState{panes: []capturedPaneRenderState{{459+ state := &capturedRenderState{
447- graphics: scene.Snapshot(),460+ route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
448- graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},461+ view: attachmentView{tabID: "tab-1"},
449- placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},462+ panes: []capturedPaneRenderState{{
450- }}}463+ stableID: "pane-1", focused: true,
464+ graphics: scene.Snapshot(),
465+ graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},
466+ placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
467+ }}}
451 graphicsState := newGraphicsOutputState()468 graphicsState := newGraphicsOutputState()
452 output := attachmentOutputWithGraphics(graphicsState)469 output := attachmentOutputWithGraphics(graphicsState)
453 output.lastCursor = cursorOut{valid: true, row: 1, col: 1}470 output.lastCursor = cursorOut{valid: true, row: 1, col: 1}
M internal/usecase/daemon/attachment_render_test.go +1 -1
@@ -365,7 +365,7 @@ func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) {
365 require.True(t, source.geometry.reconcile(d, source, nil))365 require.True(t, source.geometry.reconcile(d, source, nil))
366 366
367 target := &session{367 target := &session{
368- sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"},368+ sessionCore: sessionCore{id: "target-geometry", name: "target-geometry", incarnation: newTestLifecycle(t)},
369 ctx: source.ctx,369 ctx: source.ctx,
370 cancel: func() {},370 cancel: func() {},
371 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},371 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
M internal/usecase/daemon/attention_test.go +6 -3
@@ -295,8 +295,11 @@ func TestSwitchTabClearsAttentionEndToEnd(t *testing.T) {
295 require.True(t, selectTestAttachmentTab(sess, 1))295 require.True(t, selectTestAttachmentTab(sess, 1))
296 d.paint(sess, ac, true, nil)296 d.paint(sess, ac, true, nil)
297 data := mustOutputData(t, sends)297 data := mustOutputData(t, sends)
298- // As above: the deferred all-clients repaint has nothing new to say about298+ position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload)
299- // this client's bars, so no second frame follows.299+ require.NoError(t, err)
300+ require.Equal(t, domain.TabStableID(sess.tabs[1].stableID), position.ActiveTabID)
301+ // The tab transition publishes its position, but the deferred repaint has
302+ // no additional terminal bytes.
300 select {303 select {
301 case f := <-sends:304 case f := <-sends:
302 t.Fatalf("unexpected extra frame after ack: %v", f.Type)305 t.Fatalf("unexpected extra frame after ack: %v", f.Type)
@@ -460,7 +463,7 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
460 tab2b.attention = true463 tab2b.attention = true
461 tab2b.attentionAt = time.Unix(5, 0)464 tab2b.attentionAt = time.Unix(5, 0)
462 sess2 := &session{sessionCore: sessionCore{id: "other",465 sess2 := &session{sessionCore: sessionCore{id: "other",
463- name: "other"}, ctx: sctx2,466+ name: "other", incarnation: newTestLifecycle(t)}, ctx: sctx2,
464 cancel: cancel2,467 cancel: cancel2,
465 tabs: []*tab{tab2a, tab2b},468 tabs: []*tab{tab2a, tab2b},
466 }469 }
M internal/usecase/daemon/client_frame_routing.go +11 -0
@@ -104,7 +104,18 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
104 }104 }
105 switch message := message.(type) {105 switch message := message.(type) {
106 case protocol.Input:106 case protocol.Input:
107+ effect.uiActionID = message.ActionID
107 d.handleSequencedInputForAttachment(effect, message.InputSeq, message.Data)108 d.handleSequencedInputForAttachment(effect, message.InputSeq, message.Data)
109+ case protocol.UIFence:
110+ if !d.registerAttachmentUIFence(effect, message.ActionID) {
111+ // Invalid/overflowing fences retire this exact attachment, not just
112+ // its carriage. Cleanup is asynchronous so even refusal cannot pin
113+ // dispatch behind a blocked render send.
114+ launchCleanup := d.reserveAttachmentSendErrorCleanup(effect.capability(), effect.transport.transport)
115+ effect.End()
116+ launchCleanup()
117+ return true
118+ }
108 case protocol.Resize:119 case protocol.Resize:
109 if effect.current() {120 if effect.current() {
110 d.resizeAttachmentGeometryForLease(effect, message.Geometry())121 d.resizeAttachmentGeometryForLease(effect, message.Geometry())
M internal/usecase/daemon/copy_scroll_ownership_test.go +4 -0
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol"
7 "github.com/bnema/vev/internal/protocol/wire"8 "github.com/bnema/vev/internal/protocol/wire"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -26,6 +27,8 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
26 before := cloneComposeCache(ac.pipelineCache)27 before := cloneComposeCache(ac.pipelineCache)
27 state.bars.topRight = "retry"28 state.bars.topRight = "retry"
28 state.attachment = ac29 state.attachment = ac
30+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
31+ state.view.revision = ac.viewSnapshot().revision
29 pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)32 pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
30 if failure == "prepare" {33 if failure == "prepare" {
31 pending.frame = renderer.Frame{Width: 1}34 pending.frame = renderer.Frame{Width: 1}
@@ -44,6 +47,7 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
44 ac.replaceTransport(healthy)47 ac.replaceTransport(healthy)
45 }48 }
46 pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)49 pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
50+ state.view.revision = ac.viewSnapshot().revision
47 ac.sendMu.Lock()51 ac.sendMu.Lock()
48 require.True(t, d.emitFrame(sess, ac, &state, pending))52 require.True(t, d.emitFrame(sess, ac, &state, pending))
49 require.Equal(t, pending.cache, ac.pipelineCache)53 require.Equal(t, pending.cache, ac.pipelineCache)
M internal/usecase/daemon/copy_scroll_performance_test.go +3 -6
@@ -19,14 +19,11 @@ func TestCopyCompositionDoesNotAllocateIntermediateFrame(t *testing.T) {
19 frame := renderer.NewFrame(182, 55)19 frame := renderer.NewFrame(182, 55)
20 target := domain.Rect{Y: 1, Width: 182, Height: 53}20 target := domain.Rect{Y: 1, Width: 182, Height: 53}
21 styles := resolveStyles(nil)21 styles := resolveStyles(nil)
22- result := testing.Benchmark(func(b *testing.B) {
23- for b.Loop() {
24- composeCopyClientFrame(mode, target, frame, styles)
25- }
26- })
27 // A reusable semantic row and status text fit in 32 KiB. A second compact22 // A reusable semantic row and status text fit in 32 KiB. A second compact
28 // viewport alone needs over 150 KiB and must not be built just to copy it.23 // viewport alone needs over 150 KiB and must not be built just to copy it.
29- require.LessOrEqual(t, result.AllocedBytesPerOp(), int64(32<<10))24+ assertRenderByteBudget(t, func() {
25+ composeCopyClientFrame(mode, target, frame, styles)
26+ }, 32<<10)
30 }27 }
31 28
32 // Each operation renders two wheel movements, up three rows then down three.29 // Each operation renders two wheel movements, up three rows then down three.
M internal/usecase/daemon/copymode_test.go +4 -1
@@ -433,7 +433,10 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
433 hg.Go(func() { d.handleConn(tr) })433 hg.Go(func() { d.handleConn(tr) })
434 awaitFrame(t, sends, wire.MsgWelcome)434 awaitFrame(t, sends, wire.MsgWelcome)
435 advanceRender() // initial coordinator invalidation435 advanceRender() // initial coordinator invalidation
436- awaitFrame(t, sends, wire.MsgOutput)436+ initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
437+ require.NoError(t, err)
438+ require.NotNil(t, initial.Context)
439+ require.NotEmpty(t, initial.Context.FocusedPaneID, "a small viewport must retain its input target even when layout cannot place its cells")
437 440
438 for i := range 12 {441 for i := range 12 {
439 reads <- fmt.Appendf(nil, "line-%02d\r\n", i)442 reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
M internal/usecase/daemon/daemon_test.go +18 -1
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
5 "io"6 "io"
6 "log/slog"7 "log/slog"
7 "sync"8 "sync"
@@ -402,6 +403,13 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ..
402 return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...)403 return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...)
403 }404 }
404 405
406+func newTestLifecycle(t testing.TB) domain.SessionLifecycleID {
407+ t.Helper()
408+ id, err := domain.NewSessionLifecycleID(rand.Reader)
409+ require.NoError(t, err)
410+ return id
411+}
412+
405 func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan wire.Frame) {413 func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan wire.Frame) {
406 t.Helper()414 t.Helper()
407 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)415 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
@@ -420,11 +428,20 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
420 }428 }
421 tabs = append(tabs, tb)429 tabs = append(tabs, tb)
422 }430 }
423- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs}431+ lifecycle := newTestLifecycle(t)
432+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", incarnation: lifecycle, attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: tabs}
424 for _, tb := range tabs {433 for _, tb := range tabs {
425 publishTiledPaneOwners(sess, tb)434 publishTiledPaneOwners(sess, tb)
426 }435 }
427 ac.setSession(sess)436 ac.setSession(sess)
437+ // This fixture models an already attached client, with its initial route
438+ // position known. Handshake tests exercise the first position publication.
439+ if len(tabs) != 0 {
440+ ac.output.lastRoutePosition = protocol.RoutePosition{
441+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: sess.name},
442+ ActiveTabID: domain.TabStableID(tabs[0].stableID),
443+ }
444+ }
428 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)445 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
429 d.sessions[sess.id] = sess446 d.sessions[sess.id] = sess
430 if registerCleanup {447 if registerCleanup {
M internal/usecase/daemon/graphics_output_test.go +3 -1
@@ -284,7 +284,9 @@ func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) {
284 prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)284 prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
285 require.NoError(t, err)285 require.NoError(t, err)
286 require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap")286 require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap")
287- _, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true)287+ context := publicationTestContext()
288+ context.Publication = 1
289+ _, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true, &context)
288 require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")290 require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")
289 }291 }
290 292
M internal/usecase/daemon/input.go +1 -0
@@ -392,6 +392,7 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
392 if !admitted {392 if !admitted {
393 return nil, nil, false393 return nil, nil, false
394 }394 }
395+ effect.uiActionID = h.effect.uiActionID
395 if h.d.afterAttachmentEffectAdmitted != nil {396 if h.d.afterAttachmentEffectAdmitted != nil {
396 h.d.afterAttachmentEffectAdmitted(effect.capability())397 h.d.afterAttachmentEffectAdmitted(effect.capability())
397 }398 }
M internal/usecase/daemon/input_test.go +2 -2
@@ -834,8 +834,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
834 current.id = "current"834 current.id = "current"
835 delete(d.sessions, domain.SessionID("manual"))835 delete(d.sessions, domain.SessionID("manual"))
836 d.sessions[current.id] = current836 d.sessions[current.id] = current
837- recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}837+ recent := &session{sessionCore: sessionCore{id: "recent", name: "recent", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
838- older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}838+ older := &session{sessionCore: sessionCore{id: "older", name: "older", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
839 d.sessions[recent.id] = recent839 d.sessions[recent.id] = recent
840 d.sessions[older.id] = older840 d.sessions[older.id] = older
841 current.mruAt.Store(30)841 current.mruAt.Store(30)
M internal/usecase/daemon/move_test.go +5 -5
@@ -25,10 +25,10 @@ func TestMovePaneMutationSameSession(t *testing.T) {
25 moved.ctx, moved.cancel = movedCtx, movedCancel25 moved.ctx, moved.cancel = movedCtx, movedCancel
26 26
27 err := d.movePane(movePaneRequest{27 err := d.movePane(movePaneRequest{
28- Source: moveSessionLocator{ID: sess.id},28+ Source: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
29 SourceTabID: domain.TabStableID(sourceTab.stableID),29 SourceTabID: domain.TabStableID(sourceTab.stableID),
30 SourcePaneID: domain.PaneStableID(moved.stableID),30 SourcePaneID: domain.PaneStableID(moved.stableID),
31- Destination: moveSessionLocator{ID: sess.id},31+ Destination: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
32 DestinationTabID: domain.TabStableID(destinationTab.stableID),32 DestinationTabID: domain.TabStableID(destinationTab.stableID),
33 })33 })
34 require.NoError(t, err)34 require.NoError(t, err)
@@ -65,7 +65,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
65 d.mu.Unlock()65 d.mu.Unlock()
66 66
67 err := d.movePane(movePaneRequest{67 err := d.movePane(movePaneRequest{
68- Source: moveSessionLocator{ID: source.id},68+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
69 SourceTabID: domain.TabStableID(sourceTab.stableID),69 SourceTabID: domain.TabStableID(sourceTab.stableID),
70 SourcePaneID: domain.PaneStableID(moved.stableID),70 SourcePaneID: domain.PaneStableID(moved.stableID),
71 Destination: moveSessionLocator{ID: destination.id},71 Destination: moveSessionLocator{ID: destination.id},
@@ -105,7 +105,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
105 d.mu.Unlock()105 d.mu.Unlock()
106 106
107 err = d.movePane(movePaneRequest{107 err = d.movePane(movePaneRequest{
108- Source: moveSessionLocator{ID: source.id},108+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
109 SourceTabID: domain.TabStableID(movedTab.stableID),109 SourceTabID: domain.TabStableID(movedTab.stableID),
110 SourcePaneID: domain.PaneStableID(moved.stableID),110 SourcePaneID: domain.PaneStableID(moved.stableID),
111 Destination: moveSessionLocator{ID: destination.id},111 Destination: moveSessionLocator{ID: destination.id},
@@ -153,7 +153,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155 require.NoError(t, d.movePane(movePaneRequest{155 require.NoError(t, d.movePane(movePaneRequest{
156- Source: moveSessionLocator{ID: source.id},156+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
157 SourceTabID: domain.TabStableID(movedTab.stableID),157 SourceTabID: domain.TabStableID(movedTab.stableID),
158 SourcePaneID: domain.PaneStableID(moved.stableID),158 SourcePaneID: domain.PaneStableID(moved.stableID),
159 Destination: moveSessionLocator{ID: destination.id},159 Destination: moveSessionLocator{ID: destination.id},
M internal/usecase/daemon/navigation_overflow_test.go +18 -4
@@ -107,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
107 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}107 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
109 target.mu.Unlock()109 target.mu.Unlock()
110- return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs}110+ return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name, incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: tabs}
111 }111 }
112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
113 echo := newSession("live-echo", "echo", 1, "pane-2")113 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -278,12 +278,26 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
278 }278 }
279 279
280 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {280 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
281- d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)281+ d, alpha, ac, sends := newManualSessionWithPTYs(t, nil)
282+ // This race test generates many full views and semantic route updates.
283+ // Keep its test client reading rather than filling the bounded transport.
284+ stop, drained := make(chan struct{}), make(chan struct{})
285+ go func() {
286+ defer close(drained)
287+ for {
288+ select {
289+ case <-sends:
290+ case <-stop:
291+ return
292+ }
293+ }
294+ }()
295+ defer func() { close(stop); <-drained }()
282 alpha.mu.Lock()296 alpha.mu.Lock()
283 alpha.name = "alpha"297 alpha.name = "alpha"
284 alpha.mu.Unlock()298 alpha.mu.Unlock()
285- charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}299+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
286- echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}300+ echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo", incarnation: newTestLifecycle(t)}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
287 d.mu.Lock()301 d.mu.Lock()
288 d.sessions[echo.id] = echo302 d.sessions[echo.id] = echo
289 d.sessions[charlie.id] = charlie303 d.sessions[charlie.id] = charlie
M internal/usecase/daemon/notify_test.go +2 -1
@@ -288,7 +288,8 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
288 for _, pane := range tb.panes {288 for _, pane := range tb.panes {
289 pane.ctx, pane.cancel = wctx, wcancel289 pane.ctx, pane.cancel = wctx, wcancel
290 }290 }
291- sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}291+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
292+ ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, ActiveTabID: domain.TabStableID(tb.stableID)}
292 ac.setSession(sess)293 ac.setSession(sess)
293 d.sessions[sess.id] = sess294 d.sessions[sess.id] = sess
294 t.Cleanup(cancel)295 t.Cleanup(cancel)
M internal/usecase/daemon/pane_actions.go +40 -6
@@ -230,9 +230,9 @@ func (d *Daemon) reapPane(sess *session, tb *tab, p *pane) {
230 }230 }
231 231
232 // reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries232 // reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
233-// the immutable owner pointer; a successful attempt revokes owner publication233+// the immutable owner pointer. Non-final removal revokes it with membership;
234-// under pane.mu before releasing membership locks, so a competing close or234+// final-pane removal retains it until tab/session teardown commits, so stale
235-// move can never reap the pane from a second owner.235+// attachment snapshots cannot abandon the EOF before closing its resources.
236 func (d *Daemon) reapPaneOwner(p *pane) {236 func (d *Daemon) reapPaneOwner(p *pane) {
237 for p != nil {237 for p != nil {
238 lease := p.effectLease()238 lease := p.effectLease()
@@ -297,11 +297,17 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
297 delete(tb.panes, p.id)297 delete(tb.panes, p.id)
298 tb.bumpLayoutGenerationLocked()298 tb.bumpLayoutGenerationLocked()
299 }299 }
300- p.clearOwnerLocked()300+ if !finalPane {
301+ p.clearOwnerLocked()
302+ }
301 p.mu.Unlock()303 p.mu.Unlock()
302 tb.mu.Unlock()304 tb.mu.Unlock()
303 sess.invalidateViewsLocked()305 sess.invalidateViewsLocked()
304 sess.mu.Unlock()306 sess.mu.Unlock()
307+ attachmentTransports := make(map[*attachedClient]transportSnapshot, len(attachments))
308+ for _, ac := range attachments {
309+ attachmentTransports[ac] = ac.transportSnapshot()
310+ }
305 311
306 if finalPane {312 if finalPane {
307 for _, ac := range attachments {313 for _, ac := range attachments {
@@ -309,8 +315,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
309 ac.overlays.clearCopyModeForPane(p)315 ac.overlays.clearCopyModeForPane(p)
310 }316 }
311 }317 }
312- _ = d.closeTabLocked(sess, tb, true)318+ if err := d.closeTabLocked(sess, tb, true); err != nil {
313- return true319+ return true
320+ }
321+ if p.owner.Load() == nil {
322+ return true
323+ }
324+ // A successful no-op teardown means participant, gate, or lifecycle
325+ // validation rejected the mutation without changing either the tab list
326+ // or the pane owner. Retrying that exact state would spin forever; only
327+ // retry when the teardown made observable progress that can be consumed
328+ // by the next owner resolution.
329+ d.mu.Lock()
330+ sessionCurrent := d.sessions[sess.id] == sess
331+ sess.mu.Lock()
332+ tabCurrent := slices.Contains(sess.tabs, tb)
333+ attachmentsChanged := len(sess.attachments) != len(attachmentTransports)
334+ if !attachmentsChanged {
335+ for ac, transport := range attachmentTransports {
336+ if _, attached := sess.attachments[ac]; !attached || !ac.transportSnapshotCurrent(transport) {
337+ attachmentsChanged = true
338+ break
339+ }
340+ }
341+ }
342+ sess.mu.Unlock()
343+ d.mu.Unlock()
344+ if sessionCurrent && tabCurrent && !attachmentsChanged {
345+ return true
346+ }
347+ return false
314 }348 }
315 sess.geometry.applyTabLayout(d, sess, tb)349 sess.geometry.applyTabLayout(d, sess, tb)
316 for _, ac := range attachments {350 for _, ac := range attachments {
M internal/usecase/daemon/pane_actions_test.go +1 -1
@@ -910,7 +910,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports
910 for _, p := range tb.panes {910 for _, p := range tb.panes {
911 p.ctx, p.cancel = tctx, cancel911 p.ctx, p.cancel = tctx, cancel
912 }912 }
913- sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}913+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t)}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
914 return d, sess, oldPTY, factory914 return d, sess, oldPTY, factory
915 }915 }
916 916
A internal/usecase/daemon/pane_eof_detach_test.go +50 -0
@@ -0,0 +1,50 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestLastPaneEOFRetriesSessionTeardownAfterConcurrentDetach(t *testing.T) {
11+ for _, change := range []string{"detach", "transport replacement"} {
12+ t.Run(change, func(t *testing.T) {
13+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
14+ tb := sess.tabs[0]
15+ last := tb.focusedPane()
16+ hiddenPTY := newQuietPTY()
17+ defer func() { _ = hiddenPTY.Close() }()
18+ installTestFloating(tb, newPane("floating", hiddenPTY, domain.Size{Cols: 20, Rows: 5}), false)
19+ defer tb.closeAllPanes()
20+ changed := false
21+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
22+ if changed {
23+ return
24+ }
25+ changed = true
26+ if change == "detach" {
27+ require.True(t, d.detachIfCurrentTransport(sess, ac, ac.transportSnapshot()))
28+ } else {
29+ ac.replaceTransport(&closeTrackingTransport{})
30+ }
31+ }
32+
33+ // This is the reader's actual EOF handler. The hook makes its first
34+ // kill snapshot stale before the attachment gates are frozen.
35+ d.reapPaneOwner(last)
36+
37+ require.True(t, changed)
38+ d.mu.Lock()
39+ remaining := d.sessions[sess.id]
40+ d.mu.Unlock()
41+ require.Nil(t, remaining, "EOF must not abandon an uncommitted session teardown")
42+ require.Nil(t, last.ownerSnapshot())
43+ select {
44+ case <-hiddenPTY.done:
45+ default:
46+ t.Fatal("hidden floating PTY survived last-pane EOF")
47+ }
48+ })
49+ }
50+}
M internal/usecase/daemon/parked_route_test.go +3 -0
@@ -253,6 +253,9 @@ func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
253 require.NoError(t, err)253 require.NoError(t, err)
254 require.True(t, output.Full)254 require.True(t, output.Full)
255 require.NotContains(t, string(output.Data), "one-shot effect")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)
256 select {259 select {
257 case frame := <-sends:260 case frame := <-sends:
258 t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type)261 t.Fatalf("parked one-shot effect replayed as frame type %d", frame.Type)
M internal/usecase/daemon/performance_benchmark_test.go +4 -1
@@ -328,8 +328,11 @@ func TestPerformanceFixtureSnapshotCaptureRetainsSealedHistory(t *testing.T) {
328 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {328 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
329 for _, topology := range daemonHistoryTopologies {329 for _, topology := range daemonHistoryTopologies {
330 t.Run(topology.name, func(t *testing.T) {330 t.Run(topology.name, func(t *testing.T) {
331+ // These assertions cover coordinator counts, not history scaling.
332+ // Keep every topology; the separate history and allocation gates
333+ // retain their full 10,000-row datasets.
331 fixture := newPerformanceFixture(t, performanceConfig{334 fixture := newPerformanceFixture(t, performanceConfig{
332- size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,335+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 1,
333 })336 })
334 fixture.paintLive()337 fixture.paintLive()
335 metrics := fixture.metrics()338 metrics := fixture.metrics()
M internal/usecase/daemon/picker_test.go +8 -4
@@ -1104,8 +1104,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1104 sctx2, cancel2 := context.WithCancel(d.serveCtx)1104 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1105 defer cancel1()1105 defer cancel1()
1106 defer cancel2()1106 defer cancel2()
1107- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}1107+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", incarnation: newTestLifecycle(t), ephemeral: true, attachments: map[*attachedClient]struct{}{ac1: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1108- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}1108+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1109 ac1.setSession(sess1)1109 ac1.setSession(sess1)
1110 ac2.setSession(sess2)1110 ac2.setSession(sess2)
1111 d.sessions[sess1.id] = sess11111 d.sessions[sess1.id] = sess1
@@ -1116,6 +1116,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1116 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})1116 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
1117 d.firstPaint(sess2, ac1)1117 d.firstPaint(sess2, ac1)
1118 awaitFrame(t, sends1, wire.MsgOutput)1118 awaitFrame(t, sends1, wire.MsgOutput)
1119+ position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends1, wire.MsgRoutePosition).Payload)
1120+ require.NoError(t, err)
1121+ require.Equal(t, sess2.incarnation, position.Target.LifecycleID)
1119 require.Same(t, sess2, ac1.currentSession())1122 require.Same(t, sess2, ac1.currentSession())
1120 for len(sends1) > 0 {1123 for len(sends1) > 0 {
1121 <-sends11124 <-sends1
@@ -1159,8 +1162,9 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1159 sctx2, cancel2 := context.WithCancel(d.serveCtx)1162 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1160 defer cancel1()1163 defer cancel1()
1161 defer cancel2()1164 defer cancel2()
1162- sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}1165+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1163- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}1166+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", incarnation: newTestLifecycle(t)}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1167+ ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess1.incarnation, SessionName: sess1.name}, ActiveTabID: domain.TabStableID(sess1.tabs[0].stableID)}
1164 ac.setSession(sess1)1168 ac.setSession(sess1)
1165 d.sessions[sess1.id] = sess11169 d.sessions[sess1.id] = sess1
1166 d.sessions[sess2.id] = sess21170 d.sessions[sess2.id] = sess2
M internal/usecase/daemon/remote_picker_test.go +2 -1
@@ -935,7 +935,8 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, t
935 ac.initOverlays()935 ac.initOverlays()
936 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})936 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
937 tb.stableID = string(id) + "-tab"937 tb.stableID = string(id) + "-tab"
938- sess := &session{sessionCore: sessionCore{id: id, name: string(id), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}938+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
939+ ac.output.lastRoutePosition = protocol.RoutePosition{Target: protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}, ActiveTabID: domain.TabStableID(tb.stableID)}
939 publishTiledPaneOwners(sess, tb)940 publishTiledPaneOwners(sess, tb)
940 ac.setSession(sess)941 ac.setSession(sess)
941 d.sessions[id] = sess942 d.sessions[id] = sess
M internal/usecase/daemon/render.go +7 -0
@@ -394,6 +394,12 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
394 ac.sendMu.Unlock()394 ac.sendMu.Unlock()
395 return paintBlockedCapacity395 return paintBlockedCapacity
396 }396 }
397+ var uiFence uint64
398+ if lease != nil {
399+ if rc := attachmentRenderCoordinator(entry); rc != nil {
400+ uiFence = rc.captureUIFence(lease)
401+ }
402+ }
397 // Composition owns attachment sendMu; initialize its lazy overlay state403 // Composition owns attachment sendMu; initialize its lazy overlay state
398 // under that same ownership so concurrent fallback paints cannot observe a404 // under that same ownership so concurrent fallback paints cannot observe a
399 // partially published runtime.405 // partially published runtime.
@@ -471,6 +477,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
471 styleGeneration: applied.Generation,477 styleGeneration: applied.Generation,
472 reset: reset,478 reset: reset,
473 lease: lease,479 lease: lease,
480+ uiFence: uiFence,
474 })481 })
475 endCapture(0, ok)482 endCapture(0, ok)
476 if !ok {483 if !ok {
A internal/usecase/daemon/render_allocation_measurement_test.go +30 -0
@@ -0,0 +1,30 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+var renderAllocationMeasurementSink []byte
11+
12+func TestRenderByteMeasurementUsesBoundedRunsAndExcludesWarmup(t *testing.T) {
13+ defer func() { renderAllocationMeasurementSink = nil }()
14+ for _, size := range []int{128, 4096} {
15+ calls := 0
16+ procs := runtime.GOMAXPROCS(0)
17+ got := renderAllocatedBytesPerRun(func() {
18+ calls++
19+ if calls == 1 {
20+ renderAllocationMeasurementSink = make([]byte, 1<<20)
21+ } else {
22+ renderAllocationMeasurementSink = make([]byte, size)
23+ }
24+ })
25+ require.Equal(t, 21, calls, "one warmup and twenty samples, not a time-calibrated benchmark")
26+ require.Equal(t, procs, runtime.GOMAXPROCS(0))
27+ require.GreaterOrEqual(t, got, int64(size))
28+ require.Less(t, got, int64(size*2), "warmup bytes must not enter the per-operation measurement")
29+ }
30+}
M internal/usecase/daemon/render_allocation_test.go +20 -8
@@ -1,19 +1,31 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "runtime"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
7 )8 )
8 9
9-// Benchmark accounting measures allocated bytes, not retained heap. Setup and10+// Measure bytes with the same bounded warmup/sampling strategy as
10-// warm-up belong to the calling fixture, outside this measured operation.11+// testing.AllocsPerRun. These sequential allocation tests need a byte budget,
12+// not testing.Benchmark's default one-second timing calibration per assertion.
13+func renderAllocatedBytesPerRun(run func()) int64 {
14+ defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1))
15+ run()
16+ var before, after runtime.MemStats
17+ runtime.ReadMemStats(&before)
18+ const samples = 20
19+ for range samples {
20+ run()
21+ }
22+ runtime.ReadMemStats(&after)
23+ return int64((after.TotalAlloc - before.TotalAlloc) / samples)
24+}
25+
26+// Setup and warmup remain outside the measured operation; existing byte
27+// ceilings are unchanged. Like AllocsPerRun, do not call from parallel tests.
11 func assertRenderByteBudget(t *testing.T, run func(), maximum int64) {28 func assertRenderByteBudget(t *testing.T, run func(), maximum int64) {
12 t.Helper()29 t.Helper()
13- result := testing.Benchmark(func(b *testing.B) {30+ require.LessOrEqual(t, renderAllocatedBytesPerRun(run), maximum, "rendering must preserve the compact byte-allocation benefit")
14- for b.Loop() {
15- run()
16- }
17- })
18- require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit")
19 }31 }
M internal/usecase/daemon/render_capture.go +47 -8
@@ -4,6 +4,7 @@ import (
4 renderer "github.com/bnema/vev-vt"4 renderer "github.com/bnema/vev-vt"
5 vevgraphics "github.com/bnema/vev-vt/graphics"5 vevgraphics "github.com/bnema/vev-vt/graphics"
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
7 scopy "github.com/bnema/vev/internal/usecase/copy"8 scopy "github.com/bnema/vev/internal/usecase/copy"
8 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
9 "github.com/bnema/vev/internal/usecase/picker"10 "github.com/bnema/vev/internal/usecase/picker"
@@ -44,9 +45,12 @@ type damageReceipt struct {
44 type capturedRenderState struct {45 type capturedRenderState struct {
45 attachment *attachedClient // identity only; never dereferenced by composition46 attachment *attachedClient // identity only; never dereferenced by composition
46 sessionID domain.SessionID47 sessionID domain.SessionID
48+ route protocol.CommittedRouteIdentity
47 incarnation domain.IncarnationID49 incarnation domain.IncarnationID
48 lease *attachmentLease50 lease *attachmentLease
51+ uiFence uint64
49 view attachmentView52 view attachmentView
53+ focusedPaneID domain.PaneStableID
50 window domain.Size54 window domain.Size
51 reset bool55 reset bool
52 layout capturedTabLayout56 layout capturedTabLayout
@@ -68,6 +72,22 @@ type capturedRenderState struct {
68 receipts []damageReceipt // private capture receipts; compose must not inspect these72 receipts []damageReceipt // private capture receipts; compose must not inspect these
69 }73 }
70 74
75+// viewContext describes only this captured composition, never live session state.
76+func (s *capturedRenderState) viewContext() protocol.ViewContext {
77+ context := protocol.ViewContext{Route: s.route, TabID: s.view.tabID, FocusedPaneID: s.focusedPaneID}
78+ if s.floating.visible && s.floating.focused {
79+ context.FocusedPaneID = s.floating.pane.stableID
80+ return context
81+ }
82+ for _, pane := range s.panes {
83+ if pane.focused {
84+ context.FocusedPaneID = pane.stableID
85+ break
86+ }
87+ }
88+ return context
89+}
90+
71 type capturedTabLayout struct {91 type capturedTabLayout struct {
72 area domain.Rect92 area domain.Rect
73 focus layout.PaneID93 focus layout.PaneID
@@ -255,6 +275,7 @@ type renderCaptureRequest struct {
255 styleGeneration uint64275 styleGeneration uint64
256 reset bool276 reset bool
257 lease *attachmentLease277 lease *attachmentLease
278+ uiFence uint64
258 }279 }
259 280
260 // captureLocalRenderState is the ownership boundary for a local attachment281 // captureLocalRenderState is the ownership boundary for a local attachment
@@ -279,15 +300,22 @@ func captureLocalRenderState(
279 _, owned := sess.attachments[ac]300 _, owned := sess.attachments[ac]
280 sessionID := sess.id301 sessionID := sess.id
281 incarnation := sess.incarnation302 incarnation := sess.incarnation
303+ route := protocol.CommittedRouteIdentity{
304+ Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
305+ Ephemeral: sess.ephemeral,
306+ }
282 sess.mu.Unlock()307 sess.mu.Unlock()
283 if !owned {308 if !owned {
284 return nil, false309 return nil, false
285 }310 }
286- tb, focusedPane := sess.paneForAttachment(ac)311+ tb := sess.tabForAttachment(ac)
287 if tb == nil {312 if tb == nil {
288- tb = sess.tabForAttachment(ac)313+ return nil, false
289 }314 }
290- if tb == nil {315+ // Resolve all pane selection from one view value. Navigation after this
316+ // point invalidates the captured revision at prepared-output admission.
317+ view := ac.viewSnapshot()
318+ if view.tabID != domain.TabStableID(tb.stableID) {
291 return nil, false319 return nil, false
292 }320 }
293 321
@@ -317,8 +345,18 @@ func captureLocalRenderState(
317 defer tb.mu.Unlock()345 defer tb.mu.Unlock()
318 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}346 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
319 var focus layout.PaneID347 var focus layout.PaneID
320- if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {348+ var focusedPaneID domain.PaneStableID
321- focus = focusedPane.id349+ for _, pane := range tb.panes {
350+ if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID {
351+ focus = pane.id
352+ focusedPaneID = domain.PaneStableID(pane.stableID)
353+ break
354+ }
355+ }
356+ if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) {
357+ // A removed input target is not a committed semantic view. Its owning
358+ // topology mutation will repair/invalidate the attachment separately.
359+ return nil, false
322 }360 }
323 layoutSnap := tabLayoutSnapshot{361 layoutSnap := tabLayoutSnapshot{
324 area: area, focus: focus, placements: scratch.placements,362 area: area, focus: focus, placements: scratch.placements,
@@ -344,15 +382,16 @@ func captureLocalRenderState(
344 layoutSnap.dividers = scratch.dividers382 layoutSnap.dividers = scratch.dividers
345 }383 }
346 state := &scratch.state384 state := &scratch.state
347- view := ac.viewSnapshot()
348 window := domain.Size{}385 window := domain.Size{}
349 if view.windowSet {386 if view.windowSet {
350 window = ac.sizeSnapshot()387 window = ac.sizeSnapshot()
351 }388 }
352 *state = capturedRenderState{389 *state = capturedRenderState{
353- attachment: ac, sessionID: sessionID, incarnation: incarnation, lease: lease, view: view, window: window,390+ attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window,
354 reset: reset, bars: bars, theme: bars.theme,391 reset: reset, bars: bars, theme: bars.theme,
355- styles: request.styles, styleGeneration: request.styleGeneration,392+ uiFence: request.uiFence,
393+ focusedPaneID: focusedPaneID,
394+ styles: request.styles, styleGeneration: request.styleGeneration,
356 overlays: overlays, preview: preview,395 overlays: overlays, preview: preview,
357 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},396 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
358 floatingGeneration: tb.floating.generation,397 floatingGeneration: tb.floating.generation,
M internal/usecase/daemon/render_coordinator.go +7 -0
@@ -165,6 +165,10 @@ type renderInvalidationReservation struct {
165 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {165 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
166 c.mu.Lock()166 c.mu.Lock()
167 defer c.mu.Unlock()167 defer c.mu.Unlock()
168+ return c.reserveInvalidationForLeaseAtResizeEpochLocked(source, lease, epoch, inv)
169+}
170+
171+func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpochLocked(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
168 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||172 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
169 (source != nil && !c.attachmentRegisteredLocked(source)) ||173 (source != nil && !c.attachmentRegisteredLocked(source)) ||
170 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {174 (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source || (lease != nil && c.resize.lease != lease))) {
@@ -719,6 +723,7 @@ func (c *renderCoordinator) rebindAttachmentWithReadinessLocked(ac *attachedClie
719 return nil723 return nil
720 }724 }
721 if lease := c.leases[ac]; lease != nil {725 if lease := c.leases[ac]; lease != nil {
726+ lease.cancelUIFenceLocked()
722 lease.active = false727 lease.active = false
723 delete(c.leases, ac)728 delete(c.leases, ac)
724 }729 }
@@ -781,6 +786,7 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl
781 }786 }
782 delete(c.attachments, ac)787 delete(c.attachments, ac)
783 if lease := c.leases[ac]; lease != nil {788 if lease := c.leases[ac]; lease != nil {
789+ lease.cancelUIFenceLocked()
784 lease.active = false790 lease.active = false
785 delete(c.leases, ac)791 delete(c.leases, ac)
786 }792 }
@@ -841,6 +847,7 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
841 }847 }
842 c.torndown = true848 c.torndown = true
843 for ac, lease := range c.leases {849 for ac, lease := range c.leases {
850+ lease.cancelUIFenceLocked()
844 lease.active = false851 lease.active = false
845 delete(c.attachments, ac)852 delete(c.attachments, ac)
846 }853 }
M internal/usecase/daemon/render_coordinator_types.go +3 -0
@@ -75,6 +75,9 @@ type attachmentLease struct {
75 // outlives the coordinator's coalesced delivered map so an older capacity75 // outlives the coordinator's coalesced delivered map so an older capacity
76 // failure cannot overwrite a newer attempt.76 // failure cannot overwrite a newer attempt.
77 deliveredGeneration uint6477 deliveredGeneration uint64
78+ // UI fences are exact-lease capabilities, never inferred from wake delivery.
79+ requestedUIFence uint64
80+ pendingUIFence *pendingUIFence
78 // A lease is an immutable callback capability. Its lifecycle and delivery81 // A lease is an immutable callback capability. Its lifecycle and delivery
79 // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each82 // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each
80 // short effect boundary and never retain coordinator ownership while routing83 // short effect boundary and never retain coordinator ownership while routing
M internal/usecase/daemon/render_pipeline.go +29 -6
@@ -477,10 +477,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
477 entry.core().mu.Lock()477 entry.core().mu.Lock()
478 _, owned := entry.core().attachments[ac]478 _, owned := entry.core().attachments[ac]
479 position := protocol.RoutePosition{479 position := protocol.RoutePosition{
480- Target: protocol.ExactSessionTarget{480+ Target: state.route.Target,
481- LifecycleID: entry.core().incarnation,
482- SessionName: entry.core().name,
483- },
484 ActiveTabID: state.view.tabID,481 ActiveTabID: state.view.tabID,
485 }482 }
486 entry.core().mu.Unlock()483 entry.core().mu.Unlock()
@@ -535,8 +532,10 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
535 }532 }
536 var sendTr ports.ServerConnection533 var sendTr ports.ServerConnection
537 var sendErr error534 var sendErr error
535+ rc := attachmentRenderCoordinator(entry)
536+ confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence)
538 suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()537 suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
539- if len(data) > 0 {538+ if len(data) > 0 || prepared.ansi.context != nil {
540 sendTransport := ac.transportSnapshot()539 sendTransport := ac.transportSnapshot()
541 sendTr = sendTransport.transport540 sendTr = sendTransport.transport
542 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))541 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -555,7 +554,16 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
555 }554 }
556 }555 }
557 if sendErr == nil {556 if sendErr == nil {
558- sendErr = prepared.send(ac.echoAck.Load(), send)557+ if len(data) > 0 {
558+ sendErr = prepared.send(ac.echoAck.Load(), send)
559+ } else {
560+ sendErr = prepared.publishNoBytes(confirmUIFence, func(update protocol.UIViewUpdate) error {
561+ if sendTr.Capabilities().AsyncSend {
562+ return sendTr.SendServerAsync(update)
563+ }
564+ return sendTr.SendServer(update)
565+ })
566+ }
559 }567 }
560 if marks.attachmentEffect != nil && interruptible {568 if marks.attachmentEffect != nil && interruptible {
561 if sendErr != nil {569 if sendErr != nil {
@@ -630,6 +638,21 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
630 }638 }
631 }639 }
632 }640 }
641+ if sendErr == nil && rc != nil && prepared.ansi.boundary.ViewPublication != 0 &&
642+ (marks.attachmentEffect == nil || marks.attachmentEffect.current()) {
643+ if pending := rc.retireUIFence(state.lease, state.uiFence); pending != nil {
644+ receipt := prepared.ansi.boundary
645+ receipt.ActionID = pending.actionID
646+ sendTransport := ac.transportSnapshot()
647+ sendTr = sendTransport.transport
648+ sendErr = sendUIReceiptLocked(marks.attachmentEffect, sendTransport, receipt)
649+ if sendErr == nil {
650+ rc.finishUIFence(state.lease, pending)
651+ } else {
652+ rc.failUIFence(state.lease, pending)
653+ }
654+ }
655+ }
633 }656 }
634 ac.sendMu.Unlock()657 ac.sendMu.Unlock()
635 if suppressedGraphics && d.warnUnsupportedGraphics(ac) {658 if suppressedGraphics && d.warnUnsupportedGraphics(ac) {
M internal/usecase/daemon/render_pipeline_cache_test.go +14 -6
@@ -8,6 +8,7 @@ import (
8 8
9 renderer "github.com/bnema/vev-vt"9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/protocol/wire"12 "github.com/bnema/vev/internal/protocol/wire"
12 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
13 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -49,6 +50,8 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
49 50
50 state := cacheState("next", 2)51 state := cacheState("next", 2)
51 state.attachment = ac52 state.attachment = ac
53+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
54+ state.view.revision = ac.viewSnapshot().revision
52 ac.sendMu.Lock()55 ac.sendMu.Lock()
53 require.True(t, d.emitFrame(sess, ac, &state, pending))56 require.True(t, d.emitFrame(sess, ac, &state, pending))
54 require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")57 require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
@@ -65,6 +68,8 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
65 pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)68 pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
66 state = cacheState("next", 2)69 state = cacheState("next", 2)
67 state.attachment = ac70 state.attachment = ac
71+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
72+ state.view.revision = ac.viewSnapshot().revision
68 ac.sendMu.Lock()73 ac.sendMu.Lock()
69 require.True(t, d.emitFrame(sess, ac, &state, pending))74 require.True(t, d.emitFrame(sess, ac, &state, pending))
70 require.Equal(t, pending.cache, ac.pipelineCache)75 require.Equal(t, pending.cache, ac.pipelineCache)
@@ -322,12 +327,15 @@ func cacheState(title string, generation uint64) capturedRenderState {
322 floating := renderer.NewFrame(2, 1)327 floating := renderer.NewFrame(2, 1)
323 floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})328 floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
324 return capturedRenderState{329 return capturedRenderState{
325- reset: false,330+ route: publicationTestContext().Route,
326- layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},331+ view: attachmentView{tabID: "tab-1"},
327- panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},332+ focusedPaneID: "pane-1",
328- floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},333+ reset: false,
329- bars: barState{topRight: title, bottomRight: title},334+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
330- styles: resolveStyles(nil),335+ panes: []capturedPaneRenderState{{id: "pane", stableID: "pane-1", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
336+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
337+ bars: barState{topRight: title, bottomRight: title},
338+ styles: resolveStyles(nil),
331 }339 }
332 }340 }
333 341
M internal/usecase/daemon/render_pipeline_test.go +22 -1
@@ -14,6 +14,7 @@ import (
14 vt "github.com/bnema/vev-vt"14 vt "github.com/bnema/vev-vt"
15 renderer "github.com/bnema/vev-vt/ansi"15 renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
18 "github.com/bnema/vev/internal/testutil/replaytest"19 "github.com/bnema/vev/internal/testutil/replaytest"
19 scopy "github.com/bnema/vev/internal/usecase/copy"20 scopy "github.com/bnema/vev/internal/usecase/copy"
@@ -263,6 +264,9 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
263 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)264 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
264 state := cacheState("steady", 1)265 state := cacheState("steady", 1)
265 state.attachment = ac266 state.attachment = ac
267+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
268+ state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)
269+ state.view.revision = ac.viewSnapshot().revision
266 initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)270 initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
267 ac.sendMu.Lock()271 ac.sendMu.Lock()
268 require.True(t, d.emitFrame(sess, ac, &state, initial))272 require.True(t, d.emitFrame(sess, ac, &state, initial))
@@ -298,6 +302,21 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
298 t.Fatalf("no-byte transaction sent state frame %#v", frame)302 t.Fatalf("no-byte transaction sent state frame %#v", frame)
299 default:303 default:
300 }304 }
305+
306+ state.focusedPaneID = "pane-2"
307+ state.panes[0].stableID = "pane-2"
308+ ac.sendMu.Lock()
309+ require.True(t, d.emitFrame(sess, ac, &state, noByte))
310+ var frame wire.Frame
311+ select {
312+ case frame = <-sends:
313+ case <-time.After(time.Second):
314+ t.Fatal("timed out waiting for UI view update")
315+ }
316+ require.Equal(t, wire.MsgUIViewUpdate, frame.Type)
317+ update, err := wire.UnmarshalUIViewUpdate(frame.Payload)
318+ require.NoError(t, err)
319+ require.Equal(t, state.focusedPaneID, update.Context.FocusedPaneID)
301 }320 }
302 321
303 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {322 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
@@ -316,9 +335,11 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
316 modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})335 modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
317 }336 }
318 state := capturedRenderState{337 state := capturedRenderState{
338+ route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sess"}},
339+ view: attachmentView{tabID: "tab-1"},
319 reset: true,340 reset: true,
320 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},341 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
321- panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},342+ panes: []capturedPaneRenderState{{id: "p", stableID: "pane-1", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
322 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},343 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
323 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},344 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
324 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},345 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
M internal/usecase/daemon/render_test.go +1 -1
@@ -943,7 +943,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
943 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))943 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
944 ac := &attachedClient{tr: tr, output: newOutputStateStream()}944 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
945 ac.initOverlays()945 ac.initOverlays()
946- sess := &session{sessionCore: sessionCore{id: "s", name: "s", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}946+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{win}}
947 ac.setSession(sess)947 ac.setSession(sess)
948 948
949 d.resize(sess, ac, newSize)949 d.resize(sess, ac, newSize)
M internal/usecase/daemon/typed_connection_test.go +9 -0
@@ -276,6 +276,12 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
276 case protocol.Output:276 case protocol.Output:
277 payload, err := wire.MarshalOutput(m)277 payload, err := wire.MarshalOutput(m)
278 return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err278 return wire.Frame{Type: wire.MsgOutput, Payload: payload}, err
279+ case protocol.UIViewUpdate:
280+ payload, err := wire.MarshalUIViewUpdate(m)
281+ return wire.Frame{Type: wire.MsgUIViewUpdate, Payload: payload}, err
282+ case protocol.UIReceipt:
283+ payload, err := wire.MarshalUIReceipt(m)
284+ return wire.Frame{Type: wire.MsgUIReceipt, Payload: payload}, err
279 case protocol.Detached:285 case protocol.Detached:
280 return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(m)}, nil286 return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(m)}, nil
281 case protocol.Pong:287 case protocol.Pong:
@@ -296,6 +302,9 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
296 case protocol.RouteNavigationAction:302 case protocol.RouteNavigationAction:
297 payload, err := wire.MarshalRouteNavigationAction(m)303 payload, err := wire.MarshalRouteNavigationAction(m)
298 return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err304 return wire.Frame{Type: wire.MsgNavigateRecentRoute, Payload: payload}, err
305+ case protocol.RouteCreateSessionAction:
306+ payload, err := wire.MarshalRouteCreateSessionAction(m)
307+ return wire.Frame{Type: wire.MsgRouteCreateSession, Payload: payload}, err
299 case protocol.RouteNavigationFailure:308 case protocol.RouteNavigationFailure:
300 payload, err := wire.MarshalRouteNavigationFailure(m)309 payload, err := wire.MarshalRouteNavigationFailure(m)
301 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err310 return wire.Frame{Type: wire.MsgRouteNavigationFailure, Payload: payload}, err
A internal/usecase/daemon/ui_action_navigation_test.go +133 -0
@@ -0,0 +1,133 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+ "github.com/bnema/vev/internal/protocol/wire"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestUIActionNavigationUsesAdmittedCause(t *testing.T) {
12+ for _, actionID := range []uint64{0, 17} {
13+ for _, message := range []protocol.ServerMessage{
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},
17+ protocol.RouteCreateSessionAction{CauseActionID: 999, RequestID: 1, SnapshotGeneration: 2, Key: 3, Generation: 4, SessionName: "new"},
18+ } {
19+ t.Run(testNavigationName(message, actionID), func(t *testing.T) {
20+ _, sess, ac, sends := newManualSessionWithPTYs(t, nil)
21+ effect, ok := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
22+ require.True(t, ok)
23+ t.Cleanup(effect.End)
24+ effect.uiActionID = actionID
25+ require.NoError(t, effect.sendControl(message))
26+ frame := awaitTestValue(t, sends, "navigation was not sent")
27+ require.Equal(t, actionID, testNavigationCause(t, frame))
28+ original, err := testServerFrame(message)
29+ require.NoError(t, err)
30+ require.Equal(t, uint64(999), testNavigationCause(t, original), "sending must not mutate reusable navigation templates")
31+ })
32+ }
33+ }
34+}
35+
36+func testNavigationName(message protocol.ServerMessage, actionID uint64) string {
37+ name := "human/"
38+ if actionID != 0 {
39+ name = "automation/"
40+ }
41+ switch message.(type) {
42+ case protocol.AttachTarget:
43+ return name + "attach"
44+ case protocol.NavigationDirective:
45+ return name + "directive"
46+ case protocol.RouteNavigationAction:
47+ return name + "recent"
48+ default:
49+ return name + "create"
50+ }
51+}
52+
53+func testNavigationCause(t *testing.T, frame wire.Frame) uint64 {
54+ t.Helper()
55+ switch frame.Type {
56+ case wire.MsgAttachTarget:
57+ message, err := wire.UnmarshalAttachTarget(frame.Payload)
58+ require.NoError(t, err)
59+ 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:
65+ message, err := wire.UnmarshalRouteNavigationAction(frame.Payload)
66+ require.NoError(t, err)
67+ return message.CauseActionID
68+ case wire.MsgRouteCreateSession:
69+ message, err := wire.UnmarshalRouteCreateSessionAction(frame.Payload)
70+ require.NoError(t, err)
71+ return message.CauseActionID
72+ default:
73+ t.Fatalf("unexpected navigation frame type %d", frame.Type)
74+ return 0
75+ }
76+}
77+
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) {
117+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
118+ rc := d.attachCoordinator(sess, nil, ac, true)
119+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
120+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
121+ Generation: 9, Active: protocol.RouteRef{Key: 1, Generation: 1},
122+ ActiveEntry: testRouteEntry(1, 1, sess.name, 1, protocol.RouteKindLocal),
123+ Entries: []protocol.RecentRouteEntry{{Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: protocol.RouteKindRemote}},
124+ })
125+ for _, input := range []protocol.Input{
126+ {ActionID: 40, Data: []byte("\x1b ")},
127+ {ActionID: 41, Data: []byte("logs@edge\r")},
128+ } {
129+ require.False(t, d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, ac.transport()), input))
130+ }
131+ frame := awaitFrame(t, sends, wire.MsgNavigateRecentRoute)
132+ require.Equal(t, uint64(41), testNavigationCause(t, frame), "submission owns navigation, not the earlier palette-opening action")
133+}
A internal/usecase/daemon/ui_fence.go +125 -0
@@ -0,0 +1,125 @@
1+package daemon
2+
3+import "time"
4+
5+const uiFenceTimeout = 30 * time.Second
6+
7+// pendingUIFence is owned by coordinator.mu. Its cancellation channel releases
8+// its one lifecycle-owned timer without invoking clock methods under that lock.
9+type pendingUIFence struct {
10+ actionID uint64
11+ threshold uint64
12+ retiring bool
13+ cancel chan struct{}
14+ canceled bool
15+}
16+
17+func (p *pendingUIFence) cancelLocked() {
18+ if !p.canceled {
19+ p.canceled = true
20+ close(p.cancel)
21+ }
22+}
23+
24+func (lease *attachmentLease) cancelUIFenceLocked() {
25+ if p := lease.pendingUIFence; p != nil {
26+ p.cancelLocked()
27+ lease.pendingUIFence = nil
28+ }
29+}
30+
31+// registerUIFence reserves the capture threshold and urgent invalidation in one
32+// transaction. Dispatch must not inherit timer fallbacks that synchronously
33+// render: an ACK later on the same receive loop may be needed to make progress.
34+func (c *renderCoordinator) registerUIFence(lease *attachmentLease, actionID uint64, expired func(uint64)) bool {
35+ c.mu.Lock()
36+ defer c.mu.Unlock()
37+ if !c.leaseCurrentLocked(lease, true) || actionID == 0 || lease.pendingUIFence != nil || lease.requestedUIFence == ^uint64(0) {
38+ return false
39+ }
40+ reservation, ok := c.reserveInvalidationForLeaseAtResizeEpochLocked(lease.attachment, lease, 0, renderInvalidation{
41+ class: invalidateUrgent, producer: "ui-fence",
42+ })
43+ if !ok {
44+ return false
45+ }
46+ lease.requestedUIFence++
47+ pending := &pendingUIFence{actionID: actionID, threshold: lease.requestedUIFence, cancel: make(chan struct{})}
48+ lease.pendingUIFence = pending
49+ c.supervisor.startTaskLocked(reservation.finish)
50+ if clock := c.opts.clock; clock != nil {
51+ c.supervisor.startTaskLocked(func() {
52+ timer := clock.NewTimer(uiFenceTimeout)
53+ defer timer.Stop()
54+ select {
55+ case <-pending.cancel:
56+ return
57+ case <-timer.C():
58+ }
59+ c.mu.Lock()
60+ current := c.leaseCurrentLocked(lease, true) && lease.pendingUIFence == pending && !pending.retiring
61+ if current {
62+ lease.cancelUIFenceLocked()
63+ }
64+ c.mu.Unlock()
65+ if current && expired != nil {
66+ expired(actionID)
67+ }
68+ })
69+ }
70+ return true
71+}
72+
73+// captureUIFence is sampled after send/capacity admission and before any render
74+// capture. It must never be called while holding pane ownership.
75+func (c *renderCoordinator) captureUIFence(lease *attachmentLease) uint64 {
76+ c.mu.Lock()
77+ defer c.mu.Unlock()
78+ if !c.leaseCurrentLocked(lease, true) {
79+ return 0
80+ }
81+ return lease.requestedUIFence
82+}
83+
84+func (c *renderCoordinator) needsUIFence(lease *attachmentLease, captured uint64) bool {
85+ c.mu.Lock()
86+ defer c.mu.Unlock()
87+ return c.uiFenceEligibleLocked(lease, captured)
88+}
89+
90+func (c *renderCoordinator) uiFenceEligibleLocked(lease *attachmentLease, captured uint64) bool {
91+ if !c.leaseCurrentLocked(lease, true) {
92+ return false
93+ }
94+ p := lease.pendingUIFence
95+ return p != nil && !p.retiring && p.threshold <= captured
96+}
97+
98+// retireUIFence selects a single sender only after a successful publication.
99+// The slot stays occupied until that sender's ordered send succeeds.
100+func (c *renderCoordinator) retireUIFence(lease *attachmentLease, captured uint64) *pendingUIFence {
101+ c.mu.Lock()
102+ defer c.mu.Unlock()
103+ if !c.uiFenceEligibleLocked(lease, captured) {
104+ return nil
105+ }
106+ p := lease.pendingUIFence
107+ p.retiring = true
108+ p.cancelLocked()
109+ return p
110+}
111+
112+func (c *renderCoordinator) finishUIFence(lease *attachmentLease, pending *pendingUIFence) {
113+ c.mu.Lock()
114+ defer c.mu.Unlock()
115+ if c.leaseCurrentLocked(lease, true) && lease.pendingUIFence == pending && pending.retiring {
116+ lease.cancelUIFenceLocked()
117+ }
118+}
119+
120+// failUIFence releases a fence selected for a receipt that could not be sent.
121+// Retirement already canceled its expiry timer, so leaving the pending fence in
122+// place would permanently occupy the attachment's single fence slot.
123+func (c *renderCoordinator) failUIFence(lease *attachmentLease, pending *pendingUIFence) {
124+ c.finishUIFence(lease, pending)
125+}
A internal/usecase/daemon/ui_fence_render_test.go +169 -0
@@ -0,0 +1,169 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync/atomic"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestUIFenceDispatchReceivesACKAtFullOutputWindow(t *testing.T) {
16+ writes := make(chan []byte, 1)
17+ pty, release := newBlockingPTYWithWrites(t, writes)
18+ t.Cleanup(release)
19+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
20+ rc := d.attachCoordinator(sess, nil, ac, true)
21+ lease := rc.attachmentLease(ac)
22+ invalidated := make(chan struct{}, 1)
23+ rc.opts.onInvalidate = func(inv renderInvalidation) {
24+ if inv.producer == "ui-fence" {
25+ invalidated <- struct{}{}
26+ }
27+ }
28+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
29+ ac.output.setWindow(1)
30+ require.Equal(t, paintEmitted, d.paint(sess, ac, true, lease))
31+ initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
32+ require.NoError(t, err)
33+ require.True(t, ac.output.atCapacity())
34+ require.Empty(t, drainAllFrames(sends))
35+
36+ // A single dispatch owner receives Input, UIFence, and then the ACK that
37+ // makes room. Neither registration nor its synchronous test-clock render
38+ // fallback may wait for that same owner to dispatch the ACK.
39+ messages := make(chan protocol.ClientMessage)
40+ dispatched := make(chan bool, 1)
41+ done := make(chan struct{})
42+ go func() {
43+ defer close(done)
44+ for message := range messages {
45+ capability := captureAttachmentCapability(sess, ac, ac.transport())
46+ dispatched <- d.handleAttachmentClientMessage(capability, message)
47+ }
48+ }()
49+ t.Cleanup(func() { close(messages); awaitTestCompletion(t, done, "dispatch owner leaked") })
50+ messages <- protocol.Input{InputSeq: 1, ActionID: 9, Data: []byte("x")}
51+ require.False(t, awaitTestValue(t, dispatched, "input dispatch blocked"))
52+ require.Equal(t, []byte("x"), awaitTestValue(t, writes, "normal PTY input was bypassed"))
53+ messages <- protocol.UIFence{ActionID: 9}
54+ require.False(t, awaitTestValue(t, dispatched, "fence dispatch waited for an output ACK"))
55+ awaitTestValue(t, invalidated, "fence did not reach ACK-deferred render scheduling")
56+ require.Empty(t, drainAllFrames(sends), "full output window cannot publish or confirm the fence")
57+ messages <- protocol.Ack{Epoch: initial.Epoch, State: initial.New}
58+ require.False(t, awaitTestValue(t, dispatched, "releasing ACK did not complete"))
59+ update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload)
60+ require.NoError(t, err)
61+ receipt, err := wire.UnmarshalUIReceipt(awaitFrame(t, sends, wire.MsgUIReceipt).Payload)
62+ require.NoError(t, err)
63+ require.Equal(t, protocol.UIReceipt{ActionID: 9, Epoch: update.Epoch, State: update.State, ViewPublication: update.Context.Publication, Outcome: protocol.UIReceiptProcessed}, receipt)
64+ require.Equal(t, initial.New, update.State, "no-op confirmation must not advance the ACK chain")
65+ require.Equal(t, initial.Context.Publication+1, update.Context.Publication)
66+ require.Zero(t, ac.echoAck.Load(), "processing is not child echo")
67+ require.Empty(t, drainAllFrames(sends))
68+}
69+
70+func TestUIFencePreRegistrationCaptureCannotConfirmAction(t *testing.T) {
71+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
72+ // A coordinator without a clock leaves scheduling under the test's exact
73+ // capture boundary. The registration still uses the real dispatch owner.
74+ rc := newRenderCoordinator(renderCoordinatorOptions{})
75+ installAttachmentRenderCoordinator(sess, rc)
76+ lease := rc.attachWithReadiness(ac, true)
77+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
78+ capture := func(reset bool) *capturedRenderState {
79+ t.Helper()
80+ ac.sendMu.Lock()
81+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: reset, lease: lease, uiFence: rc.captureUIFence(lease)})
82+ if !ok {
83+ ac.sendMu.Unlock()
84+ t.Fatal("capture rejected")
85+ }
86+ return state
87+ }
88+ emit := func(state *capturedRenderState) {
89+ t.Helper()
90+ composed := composeFrame(*state, ac.pipelineCache)
91+ require.True(t, d.emitFrame(sess, ac, state, composed))
92+ }
93+ state := capture(true)
94+ dispatched := make(chan bool, 1)
95+ go func() {
96+ dispatched <- d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, ac.transport()), protocol.UIFence{ActionID: 5})
97+ }()
98+ require.False(t, awaitTestValue(t, dispatched, "fence dispatch acquired render sendMu"))
99+ emit(state)
100+ initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
101+ require.NoError(t, err)
102+ require.Empty(t, drainAllFrames(sends), "an earlier capture cannot retire a later fence")
103+ require.True(t, rc.needsUIFence(lease, rc.captureUIFence(lease)))
104+ emit(capture(false))
105+ update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload)
106+ require.NoError(t, err)
107+ receipt, err := wire.UnmarshalUIReceipt(awaitFrame(t, sends, wire.MsgUIReceipt).Payload)
108+ require.NoError(t, err)
109+ require.Equal(t, uint64(5), receipt.ActionID)
110+ require.Equal(t, initial.New, receipt.State)
111+ require.Equal(t, update.Context.Publication, receipt.ViewPublication)
112+ require.False(t, rc.needsUIFence(lease, rc.captureUIFence(lease)))
113+}
114+
115+func TestUIFenceFailedPublicationOrReceiptDoesNotConfirm(t *testing.T) {
116+ for _, failedType := range []wire.MsgType{wire.MsgUIViewUpdate, wire.MsgUIReceipt} {
117+ t.Run(map[wire.MsgType]string{wire.MsgUIViewUpdate: "metadata", wire.MsgUIReceipt: "receipt"}[failedType], func(t *testing.T) {
118+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
119+ tr := newMockServerConnection(t)
120+ sends := make(chan wire.Frame, 16)
121+ var fail atomic.Bool
122+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(frame wire.Frame) error {
123+ if fail.Load() && frame.Type == failedType {
124+ return errors.New("injected send failure")
125+ }
126+ sends <- frame
127+ return nil
128+ }).Maybe()
129+ tr.EXPECT().Close().Return(nil).Maybe()
130+ ac.tr = tr
131+ rc := newRenderCoordinator(renderCoordinatorOptions{})
132+ installAttachmentRenderCoordinator(sess, rc)
133+ lease := rc.attachWithReadiness(ac, true)
134+ cleanupEntered, releaseCleanup := make(chan struct{}), make(chan struct{})
135+ d.beforeAttachmentSendErrorCleanup = func(attachmentCapability) {
136+ close(cleanupEntered)
137+ <-releaseCleanup
138+ }
139+ t.Cleanup(func() {
140+ close(releaseCleanup)
141+ d.attachmentCleanupWg.Wait()
142+ rc.beginSessionTeardown().finish()
143+ rc.waitForTimerWorkers()
144+ })
145+ require.Equal(t, paintEmitted, d.paint(sess, ac, true, lease))
146+ initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
147+ require.NoError(t, err)
148+ require.Empty(t, drainAllFrames(sends))
149+ require.False(t, d.handleAttachmentClientMessage(captureAttachmentCapability(sess, ac, tr), protocol.UIFence{ActionID: 13}))
150+ fail.Store(true)
151+ require.Equal(t, paintEmitted, d.paint(sess, ac, false, lease))
152+ awaitTestValue(t, cleanupEntered, "failed send did not reserve exact-attachment cleanup")
153+ if failedType == wire.MsgUIViewUpdate {
154+ require.Equal(t, initial.Context.Publication, ac.output.viewPublication)
155+ require.True(t, rc.needsUIFence(lease, rc.captureUIFence(lease)), "failed metadata cannot select a receipt")
156+ } else {
157+ update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload)
158+ require.NoError(t, err)
159+ require.Equal(t, initial.Context.Publication+1, update.Context.Publication)
160+ rc.mu.Lock()
161+ pending := lease.pendingUIFence
162+ rc.mu.Unlock()
163+ require.Nil(t, pending, "failed receipt send must release its slot")
164+ require.False(t, rc.needsUIFence(lease, rc.captureUIFence(lease)))
165+ }
166+ require.Empty(t, drainAllFrames(sends), "no successful receipt after a failed publication/send")
167+ })
168+ }
169+}
A internal/usecase/daemon/ui_fence_test.go +112 -0
@@ -0,0 +1,112 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestUIFenceRegistrationAndCapturedThreshold(t *testing.T) {
13+ for _, scenario := range []string{"processed", "rebind", "detach", "teardown"} {
14+ t.Run(scenario, func(t *testing.T) {
15+ rc := newRenderCoordinator(renderCoordinatorOptions{})
16+ ac := &attachedClient{}
17+ lease := rc.attachWithReadiness(ac, true)
18+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
19+ before := rc.captureUIFence(lease)
20+ require.False(t, rc.registerUIFence(lease, 0, nil))
21+ require.True(t, rc.registerUIFence(lease, 7, nil))
22+ require.False(t, rc.registerUIFence(lease, 7, nil))
23+ require.False(t, rc.registerUIFence(lease, 8, nil))
24+ require.Nil(t, rc.retireUIFence(lease, before), "a capture already underway cannot confirm new input")
25+ captured := rc.captureUIFence(lease)
26+ require.Greater(t, captured, before)
27+ switch scenario {
28+ case "rebind":
29+ rc.mu.Lock()
30+ replacement := rc.rebindAttachmentWithReadinessLocked(ac, true)
31+ rc.mu.Unlock()
32+ require.Zero(t, rc.captureUIFence(replacement))
33+ case "detach":
34+ rc.beginDetach(ac).finish()
35+ case "teardown":
36+ rc.beginSessionTeardown().finish()
37+ }
38+ pending := rc.retireUIFence(lease, captured)
39+ if scenario != "processed" {
40+ require.Nil(t, pending)
41+ return
42+ }
43+ require.NotNil(t, pending)
44+ require.Equal(t, uint64(7), pending.actionID)
45+ require.Nil(t, rc.retireUIFence(lease, captured), "only one sender owns retirement")
46+ require.False(t, rc.registerUIFence(lease, 8, nil), "a selected receipt still owns the slot until accepted send")
47+ rc.finishUIFence(lease, pending)
48+ require.True(t, rc.registerUIFence(lease, 8, nil))
49+ require.Nil(t, rc.retireUIFence(lease, captured), "an older confirmed capture cannot complete the next action")
50+ })
51+ }
52+}
53+
54+func TestUIFenceRegistrationDoesNotRunInvalidationCallbackInline(t *testing.T) {
55+ entered, release := make(chan struct{}), make(chan struct{})
56+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) {
57+ close(entered)
58+ <-release
59+ }})
60+ lease := rc.attachWithReadiness(&attachedClient{}, true)
61+ t.Cleanup(func() { close(release); rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
62+ returned := make(chan bool, 1)
63+ go func() { returned <- rc.registerUIFence(lease, 1, nil) }()
64+ require.True(t, awaitTestValue(t, returned, "fence dispatch waited for render work"))
65+ awaitTestValue(t, entered, "invalidation work was not started")
66+}
67+
68+func TestUIFenceExpiryAndCancellation(t *testing.T) {
69+ for _, scenario := range []string{"expire", "detach", "retire"} {
70+ t.Run(scenario, func(t *testing.T) {
71+ clock := portsmocks.NewMockClock(t)
72+ timer := portsmocks.NewMockTimer(t)
73+ deadline := make(chan time.Time, 1)
74+ armed, stopped := make(chan struct{}), make(chan struct{})
75+ clock.EXPECT().NewTimer(30 * time.Second).RunAndReturn(func(time.Duration) ports.Timer { return timer }).Once()
76+ timer.EXPECT().C().RunAndReturn(func() <-chan time.Time { close(armed); return deadline }).Once()
77+ timer.EXPECT().Stop().Run(func() { close(stopped) }).Return(true).Once()
78+ // Leave an invalidation pending without a timer so registration does
79+ // not need another clock expectation unrelated to fence expiry.
80+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clock})
81+ lease := rc.attachWithReadiness(&attachedClient{}, true)
82+ rc.pending, rc.pendingUrgent, rc.armed = true, true, true
83+ expired := make(chan uint64, 1)
84+ t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
85+ require.True(t, rc.registerUIFence(lease, 42, func(id uint64) { expired <- id }))
86+ awaitTestValue(t, armed, "expiry timer was not armed")
87+ switch scenario {
88+ case "expire":
89+ deadline <- time.Time{}
90+ require.Equal(t, uint64(42), awaitTestValue(t, expired, "fence did not expire"))
91+ case "detach":
92+ rc.beginDetach(lease.attachment).finish()
93+ case "retire":
94+ pending := rc.retireUIFence(lease, rc.captureUIFence(lease))
95+ require.NotNil(t, pending)
96+ rc.finishUIFence(lease, pending)
97+ }
98+ awaitTestValue(t, stopped, "fence timer was not stopped")
99+ rc.waitForTimerWorkers()
100+ require.Empty(t, expired)
101+ require.Nil(t, rc.retireUIFence(lease, ^uint64(0)))
102+ })
103+ }
104+}
105+
106+func TestUIFenceThresholdExhaustion(t *testing.T) {
107+ rc := newRenderCoordinator(renderCoordinatorOptions{})
108+ lease := rc.attachWithReadiness(&attachedClient{}, true)
109+ lease.requestedUIFence = ^uint64(0)
110+ require.False(t, rc.registerUIFence(lease, 1, nil))
111+ require.False(t, rc.pending, "refusal must not reserve invalidation")
112+}
A internal/usecase/daemon/ui_publication_test.go +172 -0
@@ -0,0 +1,172 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/wire"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func publicationTestContext() protocol.ViewContext {
15+ return protocol.ViewContext{
16+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
17+ TabID: "tab-1", FocusedPaneID: "pane-1",
18+ }
19+}
20+
21+func TestPreparedOutputSemanticPublication(t *testing.T) {
22+ for _, scenario := range []string{"success", "send failure", "stale", "exhausted"} {
23+ t.Run(scenario, func(t *testing.T) {
24+ stream := newOutputStateStream()
25+ frame := renderer.NewFrame(3, 1)
26+ prepared, err := stream.prepare(frame, nil, true)
27+ require.NoError(t, err)
28+ context := publicationTestContext()
29+ prepared.context = &context
30+ if scenario == "stale" {
31+ stream.rebase()
32+ }
33+ if scenario == "exhausted" {
34+ stream.viewPublication = ^uint64(0)
35+ }
36+ before := stream.viewPublication
37+ called := false
38+ sendErr := errors.New("send failed")
39+ err = prepared.send(prepared.data, 9, func(output protocol.Output) error {
40+ called = true
41+ require.NotNil(t, output.Context)
42+ require.Equal(t, before+1, output.Context.Publication)
43+ require.Equal(t, context.Route, output.Context.Route)
44+ require.Equal(t, uint64(9), output.Echo)
45+ _, marshalErr := wire.MarshalOutput(output)
46+ require.NoError(t, marshalErr)
47+ if scenario == "send failure" {
48+ return sendErr
49+ }
50+ return nil
51+ })
52+ switch scenario {
53+ case "success":
54+ require.NoError(t, err)
55+ require.True(t, called)
56+ require.True(t, prepared.sent)
57+ require.Equal(t, before+1, stream.viewPublication)
58+ sideEffect, err := stream.sideEffect([]byte("query"), 9)
59+ require.NoError(t, err)
60+ require.Nil(t, sideEffect.Context)
61+ require.Equal(t, context.Route, stream.lastViewContext.Route)
62+ boundary := prepared.boundary
63+ require.Equal(t, protocol.UIReceipt{Epoch: stream.epoch, State: stream.next, ViewPublication: stream.viewPublication, Outcome: protocol.UIReceiptProcessed}, boundary)
64+ stream.rebase()
65+ require.Equal(t, boundary, prepared.boundary, "later view rebases cannot change an accepted receipt boundary")
66+ case "send failure":
67+ require.ErrorIs(t, err, sendErr)
68+ require.True(t, called)
69+ require.False(t, prepared.sent)
70+ require.Equal(t, before, stream.viewPublication)
71+ require.Zero(t, prepared.boundary)
72+ case "stale", "exhausted":
73+ if scenario == "exhausted" {
74+ require.Error(t, err)
75+ } else {
76+ require.NoError(t, err)
77+ }
78+ require.False(t, called)
79+ require.False(t, prepared.sent)
80+ require.Equal(t, before, stream.viewPublication)
81+ }
82+ })
83+ }
84+}
85+
86+func TestPreparedOutputNoBytePublication(t *testing.T) {
87+ for _, scenario := range []string{"unchanged", "fence", "focus", "failure", "stale"} {
88+ t.Run(scenario, func(t *testing.T) {
89+ stream := newOutputStateStream()
90+ frame := renderer.NewFrame(3, 1)
91+ context := publicationTestContext()
92+ first, err := stream.prepare(frame, nil, true)
93+ require.NoError(t, err)
94+ first.context = &context
95+ require.NoError(t, first.send(first.data, 0, func(protocol.Output) error { return nil }))
96+ prepared, err := stream.prepare(frame, nil, false)
97+ require.NoError(t, err)
98+ require.Empty(t, prepared.data)
99+ prepared.context = &context
100+ if scenario == "focus" {
101+ context.FocusedPaneID = "pane-2"
102+ }
103+ if scenario == "stale" {
104+ stream.rebase()
105+ }
106+ epoch, state, outstanding := stream.epoch, stream.next, stream.outstanding()
107+ called := false
108+ sendErr := errors.New("metadata send failed")
109+ err = prepared.publishNoBytes(scenario == "fence" || scenario == "failure" || scenario == "stale", func(update protocol.UIViewUpdate) error {
110+ called = true
111+ require.Equal(t, epoch, update.Epoch)
112+ require.Equal(t, state, update.State)
113+ require.Equal(t, uint64(2), update.Context.Publication)
114+ require.Equal(t, context.FocusedPaneID, update.Context.FocusedPaneID)
115+ _, marshalErr := wire.MarshalUIViewUpdate(update)
116+ require.NoError(t, marshalErr)
117+ if scenario == "failure" {
118+ return sendErr
119+ }
120+ return nil
121+ })
122+ if scenario == "failure" {
123+ require.ErrorIs(t, err, sendErr)
124+ } else {
125+ require.NoError(t, err)
126+ }
127+ require.Equal(t, scenario != "unchanged" && scenario != "stale", called)
128+ require.Equal(t, scenario != "failure" && scenario != "stale", prepared.sent)
129+ wantPublication := uint64(1)
130+ if scenario == "focus" || scenario == "fence" {
131+ wantPublication = 2
132+ }
133+ require.Equal(t, wantPublication, stream.viewPublication)
134+ require.Equal(t, epoch, stream.epoch)
135+ require.Equal(t, state, stream.next)
136+ require.Equal(t, outstanding, stream.outstanding())
137+ if scenario == "focus" || scenario == "fence" {
138+ boundary := prepared.boundary
139+ require.Equal(t, protocol.UIReceipt{Epoch: epoch, State: state, ViewPublication: wantPublication, Outcome: protocol.UIReceiptProcessed}, boundary)
140+ stream.rebase()
141+ require.Equal(t, boundary, prepared.boundary)
142+ } else {
143+ require.Zero(t, prepared.boundary, "only an accepted publication supplies a receipt boundary")
144+ }
145+ })
146+ }
147+}
148+
149+func TestCapturedViewContextUsesCapturedFocus(t *testing.T) {
150+ for _, floating := range []bool{false, true} {
151+ t.Run(map[bool]string{false: "tiled", true: "floating"}[floating], func(t *testing.T) {
152+ context := publicationTestContext()
153+ state := capturedRenderState{
154+ route: context.Route,
155+ view: attachmentView{tabID: context.TabID, paneID: "not-the-rendered-focus"},
156+ panes: []capturedPaneRenderState{
157+ {stableID: "other-pane"},
158+ {stableID: context.FocusedPaneID, focused: true},
159+ },
160+ floating: capturedFloatingRenderState{visible: floating, focused: floating, pane: capturedPaneRenderState{stableID: "floating-pane"}},
161+ }
162+ got := state.viewContext()
163+ if floating {
164+ context.FocusedPaneID = "floating-pane"
165+ }
166+ require.Equal(t, context, got)
167+ state.route.Target.SessionName = "renamed"
168+ state.panes[1].stableID = "replacement"
169+ require.Equal(t, context, got, "published values do not alias capture storage")
170+ })
171+ }
172+}
A internal/usecase/daemon/ui_receipt.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+)
8+
9+func (d *Daemon) registerAttachmentUIFence(effect *attachmentEffect, actionID uint64) bool {
10+ if !effect.current() {
11+ return false
12+ }
13+ capability := effect.capability()
14+ rc := attachmentRenderCoordinator(capability.sess)
15+ return rc != nil && rc.registerUIFence(capability.lease, actionID, func(id uint64) {
16+ d.expireAttachmentUIFence(capability, id)
17+ })
18+}
19+
20+func (d *Daemon) expireAttachmentUIFence(capability attachmentCapability, actionID uint64) {
21+ effect, ok := capability.ac.beginAttachmentEffect(capability)
22+ if !ok {
23+ return
24+ }
25+ defer effect.End()
26+ // Unavailability confirms no publication. In particular, do not synthesize
27+ // a successful boundary from mutable output state when rendering timed out.
28+ if err := effect.sendControl(protocol.UIReceipt{ActionID: actionID, Outcome: protocol.UIReceiptUnavailable}); err != nil {
29+ launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport)
30+ effect.End()
31+ launchCleanup()
32+ }
33+}
34+
35+// sendUIReceiptLocked shares the attachment's Output/UIViewUpdate send ordering.
36+// Callers hold sendMu, never coordinator.mu. A nil effect is the existing direct
37+// render fixture path, not a separate production transport owner.
38+func sendUIReceiptLocked(effect *attachmentEffect, transport transportSnapshot, receipt protocol.UIReceipt) error {
39+ if transport.transport == nil {
40+ return errors.New("client transport is nil")
41+ }
42+ if effect != nil {
43+ if !effect.current() || !effect.beginTransportSend(transport) {
44+ return errAttachmentTransition
45+ }
46+ defer effect.endTransportSend()
47+ }
48+ var err error
49+ if transport.transport.Capabilities().AsyncSend {
50+ err = transport.transport.SendServerAsync(receipt)
51+ } else {
52+ err = transport.transport.SendServer(receipt)
53+ }
54+ if err != nil && effect != nil {
55+ effect.reportTransportFailure(transport)
56+ }
57+ return err
58+}
A internal/usecase/daemon/ui_render_publication_test.go +94 -0
@@ -0,0 +1,94 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol/wire"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRenderPublicationUsesCapturedIdentityAndRevision(t *testing.T) {
12+ for _, scenario := range []string{"unchanged", "rename after capture", "view replaced after capture"} {
13+ t.Run(scenario, func(t *testing.T) {
14+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
15+ ac.sendMu.Lock()
16+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: true})
17+ if !ok {
18+ ac.sendMu.Unlock()
19+ t.Fatal("capture rejected")
20+ }
21+ want := state.viewContext()
22+ want.Publication = 1
23+ require.NoError(t, want.Validate())
24+ composed := composeFrame(*state, ac.pipelineCache)
25+ switch scenario {
26+ case "rename after capture":
27+ sess.mu.Lock()
28+ sess.name = "renamed"
29+ sess.mu.Unlock()
30+ case "view replaced after capture":
31+ view := ac.viewSnapshot()
32+ view.revision++
33+ ac.publishView(view)
34+ }
35+ require.True(t, d.emitFrame(sess, ac, state, composed)) // releases sendMu
36+ if scenario == "view replaced after capture" {
37+ require.Empty(t, drainAllFrames(sends))
38+ require.Zero(t, ac.output.viewPublication)
39+ require.Zero(t, ac.output.next)
40+ return
41+ }
42+ output, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
43+ require.NoError(t, err)
44+ require.Equal(t, &want, output.Context)
45+ require.Equal(t, state.view.revision, output.ViewRevision)
46+ require.Equal(t, want, ac.output.lastViewContext)
47+ require.Equal(t, domain.PaneStableID(sess.tabs[0].focusedPane().stableID), output.Context.FocusedPaneID)
48+ })
49+ }
50+}
51+
52+func TestRenderPublishesContextWithoutTerminalBytes(t *testing.T) {
53+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
54+ paint := func(reset bool) {
55+ t.Helper()
56+ ac.sendMu.Lock()
57+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: reset})
58+ if !ok {
59+ ac.sendMu.Unlock()
60+ t.Fatal("capture rejected")
61+ }
62+ composed := composeFrame(*state, ac.pipelineCache)
63+ require.True(t, d.emitFrame(sess, ac, state, composed))
64+ }
65+ paint(true)
66+ initial, err := wire.UnmarshalOutput(awaitFrame(t, sends, wire.MsgOutput).Payload)
67+ require.NoError(t, err)
68+ require.NotNil(t, initial.Context)
69+ beforeOutstanding := ac.output.outstanding()
70+ sess.mu.Lock()
71+ sess.name = "renamed"
72+ sess.mu.Unlock()
73+ // The test's bars contain no session name, so only semantic context changes.
74+ paint(false)
75+ update, err := wire.UnmarshalUIViewUpdate(awaitFrame(t, sends, wire.MsgUIViewUpdate).Payload)
76+ require.NoError(t, err)
77+ require.Equal(t, initial.Epoch, update.Epoch)
78+ require.Equal(t, initial.New, update.State)
79+ require.Equal(t, initial.Context.Publication+1, update.Context.Publication)
80+ require.Equal(t, "renamed", update.Context.Route.Target.SessionName)
81+ require.Equal(t, initial.Context.Route.Target.LifecycleID, update.Context.Route.Target.LifecycleID)
82+ position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload)
83+ require.NoError(t, err)
84+ require.Equal(t, update.Context.Route.Target, position.Target)
85+ require.Equal(t, beforeOutstanding, ac.output.outstanding(), "metadata does not create an ACK obligation")
86+ require.Empty(t, drainAllFrames(sends))
87+ paint(false)
88+ require.Empty(t, drainAllFrames(sends), "unchanged view without a fence sends nothing")
89+ require.Equal(t, update.Context.Publication, ac.output.viewPublication)
90+ query, err := ac.output.sideEffect([]byte("query"), 0)
91+ require.NoError(t, err)
92+ require.Nil(t, query.Context)
93+ require.Equal(t, update.Context, ac.output.lastViewContext)
94+}
M internal/usecase/daemon/wire.go +0 -11
@@ -36,17 +36,6 @@ func serverError(code uint16, text string) protocol.ErrorMsg {
36 return protocol.ErrorMsg{Code: code, Text: text}36 return protocol.ErrorMsg{Code: code, Text: text}
37 }37 }
38 38
39-func serverOutputState(data []byte, baseState, state, echoAck uint64) (protocol.Output, error) {
40- message := protocol.Output{
41- Epoch: 1, Base: baseState, New: state, Echo: echoAck,
42- Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: data,
43- }
44- if err := protocol.ValidateOutput(message); err != nil {
45- return protocol.Output{}, err
46- }
47- return message, nil
48-}
49-
50 func serverDetached(reason uint8) protocol.Detached { return protocol.Detached{Reason: reason} }39 func serverDetached(reason uint8) protocol.Detached { return protocol.Detached{Reason: reason} }
51 func serverPong() protocol.Pong { return protocol.Pong{} }40 func serverPong() protocol.Pong { return protocol.Pong{} }
52 func serverSessions(infos []protocol.SessionInfo) protocol.Sessions {41 func serverSessions(infos []protocol.SessionInfo) protocol.Sessions {
A internal/usecase/theme/scanner_batch_test.go +43 -0
@@ -0,0 +1,43 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestScannerBatchBoundaryPreservesBytesAndReuse(t *testing.T) {
12+ for _, test := range []struct {
13+ name, input string
14+ pending bool
15+ }{
16+ {"empty", "", false},
17+ {"ordinary", "hello", false},
18+ {"standalone escape", "\x1b", false},
19+ {"split color", "x\x1b]10;#12", true},
20+ {"split scheme", "\x1b[?997;", true},
21+ } {
22+ t.Run(test.name, func(t *testing.T) {
23+ var scanner Scanner
24+ var out bytes.Buffer
25+ callbacks := 0
26+ scan := func(data string) {
27+ scanner.Scan([]byte(data), func(int, renderer.RGB) { callbacks++ }, func(int, renderer.RGB) { callbacks++ }, func(bool) { callbacks++ }, func(data []byte) { out.Write(data) })
28+ }
29+ require.False(t, scanner.Pending())
30+ scan(test.input)
31+ require.Equal(t, test.pending, scanner.Pending())
32+ scanner.EndBatch(func(data []byte) { out.Write(data) })
33+ require.False(t, scanner.Pending())
34+ require.Equal(t, test.input, out.String(), "ending a batch flushes undecided bytes, not fabricated replies")
35+ require.Zero(t, callbacks)
36+ scanner.EndBatch(func(data []byte) { out.Write(data) })
37+ require.Equal(t, test.input, out.String(), "a boundary cannot replay bytes")
38+ scan("\x1b]10;#123456\a")
39+ require.Equal(t, 1, callbacks, "the reusable scanner still handles normal terminal responses")
40+ require.False(t, scanner.Pending())
41+ })
42+ }
43+}
M internal/usecase/theme/theme.go +14 -0
@@ -124,6 +124,20 @@ type Scanner struct {
124 pending []byte124 pending []byte
125 }125 }
126 126
127+// Pending reports undecided terminal-response bytes at the input owner's
128+// admission boundary. Like Scan, it is called only by that owner.
129+func (s *Scanner) Pending() bool { return len(s.pending) != 0 }
130+
131+// EndBatch forwards an admitted batch's undecided suffix as ordinary input.
132+// It neither invents a terminal response nor closes the reusable scanner.
133+func (s *Scanner) EndBatch(onBytes func([]byte)) {
134+ pending := s.pending
135+ s.pending = nil
136+ if len(pending) != 0 {
137+ onBytes(pending)
138+ }
139+}
140+
127 // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>141 // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
128 // responses terminated by BEL or ST.142 // responses terminated by BEL or ST.
129 // All non-matching bytes are emitted through onBytes in original order, and a143 // All non-matching bytes are emitted through onBytes in original order, and a
M scripts/remote-picker-harness/visual_probe_test.go +19 -8
@@ -30,9 +30,20 @@ func (t *probeTestTransport) Send(frame wire.Frame) error {
30 func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") }30 func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") }
31 func (*probeTestTransport) Close() error { return nil }31 func (*probeTestTransport) Close() error { return nil }
32 32
33+func probeTestSemanticOutput(output protocol.Output) protocol.Output {
34+ if output.New != 0 && output.Context == nil {
35+ output.Context = &protocol.ViewContext{
36+ Publication: output.New,
37+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "probe-fixture"}},
38+ TabID: "tab-1", FocusedPaneID: "pane-1",
39+ }
40+ }
41+ return output
42+}
43+
33 func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame {44 func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame {
34 t.Helper()45 t.Helper()
35- payload, err := wire.MarshalOutput(output)46+ payload, err := wire.MarshalOutput(probeTestSemanticOutput(output))
36 if err != nil {47 if err != nil {
37 t.Fatalf("marshal output: %v", err)48 t.Fatalf("marshal output: %v", err)
38 }49 }
@@ -62,7 +73,7 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
62 },73 },
63 } {74 } {
64 t.Run(tt.name, func(t *testing.T) {75 t.Run(tt.name, func(t *testing.T) {
65- result := probe.apply(tt.output)76+ result := probe.apply(probeTestSemanticOutput(tt.output))
66 if !result.Accepted || !result.StateBearing {77 if !result.Accepted || !result.StateBearing {
67 t.Fatalf("result = %+v, want accepted state-bearing output", result)78 t.Fatalf("result = %+v, want accepted state-bearing output", result)
68 }79 }
@@ -88,9 +99,9 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
88 func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {99 func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
89 probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})100 probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
90 outputSize := domain.Size{Cols: 48, Rows: 3}101 outputSize := domain.Size{Cols: 48, Rows: 3}
91- result := probe.apply(protocol.Output{102+ result := probe.apply(probeTestSemanticOutput(protocol.Output{
92 Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),103 Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
93- })104+ }))
94 105
95 require.True(t, result.Accepted)106 require.True(t, result.Accepted)
96 require.Equal(t, outputSize.Cols, probe.screen.Columns())107 require.Equal(t, outputSize.Cols, probe.screen.Columns())
@@ -122,11 +133,11 @@ func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
122 t.Run(tt.name, func(t *testing.T) {133 t.Run(tt.name, func(t *testing.T) {
123 probe := newVisualProbe(size)134 probe := newVisualProbe(size)
124 first := protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}135 first := protocol.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
125- if result := probe.apply(first); !result.Accepted {136+ if result := probe.apply(probeTestSemanticOutput(first)); !result.Accepted {
126 t.Fatal("seed output was rejected")137 t.Fatal("seed output was rejected")
127 }138 }
128 beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)139 beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
129- result := probe.apply(tt.output)140+ result := probe.apply(probeTestSemanticOutput(tt.output))
130 if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {141 if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {
131 t.Fatalf("result = %+v, want rejected output without ACK", result)142 t.Fatalf("result = %+v, want rejected output without ACK", result)
132 }143 }
@@ -179,7 +190,7 @@ func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
179 } {190 } {
180 t.Run(tt.name, func(t *testing.T) {191 t.Run(tt.name, func(t *testing.T) {
181 probe := newVisualProbe(size)192 probe := newVisualProbe(size)
182- if tt.seed != nil && !probe.apply(*tt.seed).Accepted {193+ if tt.seed != nil && !probe.apply(probeTestSemanticOutput(*tt.seed)).Accepted {
183 t.Fatal("seed output was rejected")194 t.Fatal("seed output was rejected")
184 }195 }
185 beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)196 beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
@@ -312,7 +323,7 @@ func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
312 if state == 1 {323 if state == 1 {
313 output.Full = true324 output.Full = true
314 }325 }
315- if result := probe.apply(output); !result.Accepted {326+ if result := probe.apply(probeTestSemanticOutput(output)); !result.Accepted {
316 t.Fatalf("probe output %d was rejected", state)327 t.Fatalf("probe output %d was rejected", state)
317 }328 }
318 }329 }

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