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
1616 ```
1717
1818 - 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`.
2020 - Use Mockery v3 consistently across Go projects.
2121 - `make test` runs `go test ./... -race`.
2222 - `make lint` checks `goimports -l`, then `go vet`.
M Makefile +2 -2
@@ -19,8 +19,8 @@ lint:
1919
2020 mocks:
2121 @version="$$(mockery version 2>/dev/null || true)"; \
22- if [ "$$version" != "v3.7.1" ]; then \
23- echo "mockery v3.7.1 required; install with: go install github.com/vektra/mockery/v3@v3.7.1" >&2; \
22+ if [ "$$version" != "v3.7.4" ]; then \
23+ echo "mockery v3.7.4 required; install with: go install github.com/vektra/mockery/v3@v3.7.4" >&2; \
2424 exit 1; \
2525 fi
2626 mockery
M README.md +8 -0
@@ -36,6 +36,9 @@ vev ls [<host>|--all] list sessions
3636 vev host add|rm <host> manage remote hosts
3737 vev host list list remote hosts
3838 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
3942 ```
4043
4144 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]
7982
8083 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.
8184
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+
8289 ## Scripting
8390
8491 `vev cmd` controls a running daemon without starting one:
@@ -97,6 +104,7 @@ Use `vev cmd --help` for commands and targeting options.
97104 - [Terminal compatibility](docs/terminal.md)
98105 - [Remote resilience](docs/remote-resilience.md)
99106 - [Durable session recovery](docs/durable-session-recovery.md)
107+- [UI driver](docs/ui-driver.md): headless capture/control and opt-in interactive observation
100108 - [Performance](docs/performance.md)
101109
102110 ## 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
1111 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
1212 - `internal/usecase`: client, daemon, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters.
1313 - `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.
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.
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/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.
1618 - `pkg`: reusable packages that never import `internal`.
1719
1820 ## Dependency direction
@@ -29,6 +31,12 @@ main → app → usecase → ports
2931
3032 `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`.
3133
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+
3240 ## Session composition
3341
3442 ```text
@@ -48,4 +56,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
4856 - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`.
4957 - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`.
5058 - 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
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.5.1
6+ github.com/bnema/vev-vt v0.5.2-0.20260906085439-e69c4686b8bb
77 github.com/stretchr/testify v1.11.1
8+ golang.org/x/sys v0.42.0
89 )
910
1011 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() {
9999 }
100100 }()
101101 scheduleACK := func() {
102- if ackTimerC == nil {
102+ armed := ackTimerC == nil
103+ if armed {
103104 if ackTimer == nil {
104105 ackTimer = t.clock.NewTimer(maxACKDelay)
105106 } else {
@@ -111,26 +112,30 @@ func (t *Transport) ackScheduleLoop() {
111112 hook := t.afterACKScheduled
112113 t.mu.Unlock()
113114 if hook != nil {
114- hook()
115+ hook(armed)
115116 }
116117 }
117118 dispatchACK := func() {
118119 seq, ok := t.takeACK()
119- if !ok {
120- return
121- }
122- select {
123- case t.ackSend <- seq:
124- t.mu.Lock()
125- hook := t.afterACKDispatched
126- t.mu.Unlock()
127- if hook != nil {
128- hook()
120+ dispatched := false
121+ if ok {
122+ select {
123+ case t.ackSend <- seq:
124+ dispatched = true
125+ default:
126+ // Preserve the cumulative maximum until the bounded ACK sender
127+ // has capacity again.
128+ t.queueACK(seq)
129129 }
130- default:
131- // Preserve the cumulative maximum until the bounded ACK sender has
132- // capacity again.
133- t.queueACK(seq)
130+ }
131+ // A queued wake can outlive the deadline that already consumed its
132+ // cumulative ACK. Signal completion even when this attempt is empty;
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)
134139 }
135140 }
136141
M internal/adapters/dgram/network_sim_test.go +20 -13
@@ -400,8 +400,8 @@ func TestTransportFloodClassification(t *testing.T) {
400400 primerWritten := make(chan struct{}, 1)
401401 primerAcknowledged := make(chan struct{}, 1)
402402 ackWakeAccepted := make(chan struct{}, 128)
403- ackScheduled := make(chan struct{}, 128)
404- ackDispatched := make(chan struct{}, 128)
403+ ackScheduled := make(chan bool, 128)
404+ ackDispatched := make(chan bool, 128)
405405 ackWritten := make(chan struct{}, 128)
406406 aPC.replaceAfterWrite(func() {
407407 select {
@@ -441,20 +441,24 @@ func TestTransportFloodClassification(t *testing.T) {
441441 default:
442442 }
443443 }
444- b.afterACKScheduled = func() {
444+ b.afterACKScheduled = func(armed bool) {
445445 select {
446- case ackScheduled <- struct{}{}:
446+ case ackScheduled <- armed:
447447 default:
448448 }
449449 }
450- b.afterACKDispatched = func() {
450+ b.afterACKDispatchAttempt = func(dispatched bool) {
451451 select {
452- case ackDispatched <- struct{}{}:
452+ case ackDispatched <- dispatched:
453453 default:
454454 }
455455 }
456456 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")})
458462 if err := a.SendAsync(wire.Frame{Type: wire.MsgOutput, Payload: primer}); err != nil {
459463 t.Fatal(err)
460464 }
@@ -464,13 +468,16 @@ func TestTransportFloodClassification(t *testing.T) {
464468 for range 40 {
465469 clk.advance(25 * time.Millisecond)
466470 if awaitingPrimerACK {
471+ if !awaitResult(t, ackDispatched, "primer ACK dispatch") {
472+ t.Fatal("primer deadline did not dispatch its ACK")
473+ }
467474 awaitSignal(t, primerAcknowledged, "primer ACK write")
475+ awaitSignal(t, ackWritten, "primer ACK write completion")
468476 awaitingPrimerACK = false
469477 }
470478 if flushAndAwait(t, forward, bPC, processed) > 0 {
471479 awaitSignal(t, ackWakeAccepted, "primer accepted ACK wake")
472- awaitSignal(t, ackScheduled, "primer ACK scheduling")
473- awaitingPrimerACK = true
480+ awaitingPrimerACK = awaitResult(t, ackScheduled, "primer ACK scheduling")
474481 }
475482 flushAndAwait(t, reverse, aPC, processed)
476483 a.mu.Lock()
@@ -582,15 +589,15 @@ func TestTransportFloodClassification(t *testing.T) {
582589 }
583590 }
584591 if awaitingACKWrite {
585- awaitSignal(t, ackDispatched, "cumulative ACK dispatch")
586- awaitSignal(t, ackWritten, "cumulative ACK write")
592+ if awaitResult(t, ackDispatched, "cumulative ACK dispatch attempt") {
593+ awaitSignal(t, ackWritten, "cumulative ACK write")
594+ }
587595 awaitingACKWrite = false
588596 }
589597 if flushAndAwait(t, forward, bPC, processed) > 0 {
590598 select {
591599 case <-ackWakeAccepted:
592- awaitSignal(t, ackScheduled, "cumulative ACK scheduling")
593- awaitingACKWrite = true
600+ awaitingACKWrite = awaitResult(t, ackScheduled, "cumulative ACK scheduling")
594601 default:
595602 }
596603 }
M internal/adapters/dgram/ssh_dialer.go +18 -1
@@ -107,6 +107,13 @@ type RemoteDialer struct {
107107 ProbeTimeout time.Duration
108108 Log *slog.Logger
109109 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
110117 }
111118
112119 func NewRemoteDialer(target, _ string) RemoteDialer {
@@ -117,6 +124,16 @@ func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer
117124 return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
118125 }
119126
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+
120137 func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
121138 if err := ctx.Err(); err != nil {
122139 return nil, err
@@ -133,7 +150,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (wire.Transport, error) {
133150 defer bootstrapCancel()
134151
135152 var stderr limitedBuffer
136- proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)
153+ proc := d.bootstrapProcess(bootstrapCtx, &stderr)
137154 stdout, err := proc.StdoutPipe()
138155 if err != nil {
139156 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
M internal/adapters/dgram/transport.go +3 -3
@@ -248,9 +248,9 @@ type Transport struct {
248248 // authenticated packet has been fully processed. It is read under mu.
249249 afterPacketProcessed func()
250250 // ACK hooks are test-only and observers must be non-blocking.
251- afterACKWakeAccepted func()
252- afterACKScheduled func()
253- afterACKDispatched func()
251+ afterACKWakeAccepted func()
252+ afterACKScheduled func(armed bool)
253+ afterACKDispatchAttempt func(dispatched bool)
254254 // outboundMu orders first transmissions and paced batches. Retransmits use
255255 // writeMu directly because they do not establish new sequence order.
256256 outboundMu sync.Mutex
M internal/adapters/dgram/wire_test_helpers_test.go +8 -0
@@ -1,11 +1,19 @@
11 package dgram
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/protocol"
56 "github.com/bnema/vev/internal/protocol/wire"
67 )
78
89 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+ }
917 payload, err := wire.MarshalOutput(m)
1018 if err != nil {
1119 panic(err)
M internal/adapters/remote/remote_factory.go +41 -2
@@ -2,6 +2,7 @@ package remote
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "log/slog"
78
@@ -24,6 +25,15 @@ type DialerFactory struct {
2425 observer ports.SerializedRuntimeObserver
2526 }
2627
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+
2737 func NewDialerFactory() DialerFactory { return DialerFactory{} }
2838
2939 func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
@@ -31,6 +41,19 @@ func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserve
3141 }
3242
3343 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) {
3457 switch mode {
3558 case TransportUDP:
3659 if log != nil {
@@ -38,12 +61,24 @@ func (f DialerFactory) DialerForRemote(target, session string, mode TransportMod
3861 }
3962 dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
4063 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+ }
4170 return dialer, nil
4271 case TransportStdio:
4372 if log != nil {
4473 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
4574 }
46- return stdioDialer{target: target, log: log, observer: f.observer}, nil
75+ 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
4782 default:
4883 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
4984 }
@@ -53,8 +88,12 @@ type stdioDialer struct {
5388 target string
5489 log *slog.Logger
5590 observer ports.SerializedRuntimeObserver
91+ launch *EndpointLaunch
5692 }
5793
5894 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))
6099 }
M internal/adapters/remote/remote_factory_test.go +15 -0
@@ -47,6 +47,21 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
4747 }
4848 }
4949
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+
5065 func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
5166 factory := NewDialerFactory()
5267
M internal/adapters/sessionwire/client.go +12 -1
@@ -111,12 +111,16 @@ func decodeServer(frame wire.Frame) (protocol.ServerMessage, error) {
111111 return wire.UnmarshalSamePeerSwitchFailure(frame.Payload)
112112 case wire.MsgParkedRouteResponse:
113113 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)
114118 case wire.MsgHello, wire.MsgInput, wire.MsgResize, wire.MsgDetach, wire.MsgPing,
115119 wire.MsgList, wire.MsgKill, wire.MsgTheme, wire.MsgAck, wire.MsgImagePush,
116120 wire.MsgClientNotice, wire.MsgCommand, wire.MsgOutputResetRequest,
117121 wire.MsgRemotePreviewRequest, wire.MsgRouteAttentionSubscription,
118122 wire.MsgSamePeerSwitchRequest, wire.MsgParkedRouteRequest,
119- wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure:
123+ wire.MsgRecentRouteSnapshot, wire.MsgSessionCreationFailure, wire.MsgUIFence:
120124 return nil, ErrWrongDirection
121125 default:
122126 return nil, ErrUnknownMessageType
@@ -161,6 +165,9 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
161165 return wire.Frame{Type: wire.MsgCommand, Payload: payload}, err
162166 case protocol.OutputResetRequest:
163167 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
164171 case protocol.RemotePreviewRequest:
165172 payload := wire.MarshalRemotePreviewRequest(m)
166173 if payload == nil {
@@ -240,6 +247,10 @@ func encodeClient(message protocol.ClientMessage) (wire.Frame, error) {
240247 if m != nil {
241248 return encodeClient(*m)
242249 }
250+ case *protocol.UIFence:
251+ if m != nil {
252+ return encodeClient(*m)
253+ }
243254 case *protocol.RemotePreviewRequest:
244255 if m != nil {
245256 return encodeClient(*m)
M internal/adapters/sessionwire/client_test.go +9 -4
@@ -34,6 +34,8 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
3434 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}, typeID: wire.MsgClientNotice},
3535 {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}, typeID: wire.MsgCommand},
3636 {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},
3739 {name: "preview", message: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, typeID: wire.MsgRemotePreviewRequest},
3840 {name: "attention", message: protocol.RouteAttentionSubscription{Targets: []protocol.RouteAttentionTarget{}}, typeID: wire.MsgRouteAttentionSubscription},
3941 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}, typeID: wire.MsgSamePeerSwitchRequest},
@@ -50,11 +52,14 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
5052 require.Equal(t, tt.typeID, raw.sent.Type)
5153 got, err := decodeClient(raw.sent)
5254 require.NoError(t, err)
53- if message, ok := tt.message.(*protocol.SessionCreationFailure); ok {
55+ switch message := tt.message.(type) {
56+ case *protocol.SessionCreationFailure:
5457 require.Equal(t, *message, got)
55- return
58+ case *protocol.UIFence:
59+ require.Equal(t, *message, got)
60+ default:
61+ require.Equal(t, tt.message, got)
5662 }
57- require.Equal(t, tt.message, got)
5863 })
5964 }
6065 }
@@ -68,7 +73,7 @@ func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
6873 }{
6974 {name: "welcome", message: protocol.Welcome{SessionID: "session"}},
7075 {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()}},
7277 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
7378 {name: "pong", message: protocol.Pong{}},
7479 {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) {
234234 return wire.UnmarshalRouteNavigationFailure(frame.Payload)
235235 case wire.MsgSessionCreationFailure:
236236 return wire.UnmarshalSessionCreationFailure(frame.Payload)
237+ case wire.MsgUIFence:
238+ return wire.UnmarshalUIFence(frame.Payload)
237239 case wire.MsgWelcome, wire.MsgError, wire.MsgOutput, wire.MsgDetached, wire.MsgPong,
238240 wire.MsgSessions, wire.MsgCommandResult, wire.MsgNavigationAction,
239241 wire.MsgAttachTarget, wire.MsgRemotePreviewResponse,
240242 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:
242245 return nil, ErrWrongDirection
243246 default:
244247 return nil, ErrUnknownMessageType
@@ -258,6 +261,12 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
258261 case protocol.Output:
259262 payload, err := wire.MarshalOutput(m)
260263 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
261270 case protocol.Detached:
262271 if err := protocol.ValidateDetached(m); err != nil {
263272 return wire.Frame{}, err
@@ -323,6 +332,14 @@ func encodeServer(message protocol.ServerMessage) (wire.Frame, error) {
323332 if m != nil {
324333 return encodeServer(*m)
325334 }
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+ }
326343 case *protocol.Detached:
327344 if m != nil {
328345 return encodeServer(*m)
M internal/adapters/sessionwire/server_test.go +12 -2
@@ -113,7 +113,9 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
113113 lease := protocol.ParkedRouteLeaseID{1}
114114 welcome := protocol.Welcome{SessionID: "session"}
115115 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()}
117119 detached := protocol.Detached{Reason: protocol.ReasonDetach}
118120 pong := protocol.Pong{}
119121 sessions := protocol.Sessions{}
@@ -131,6 +133,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
131133
132134 outputPayload, err := wire.MarshalOutput(output)
133135 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)
134140 identityPayload, err := wire.MarshalCommittedRouteIdentity(identity)
135141 require.NoError(t, err)
136142 routeActionPayload, err := wire.MarshalRouteNavigationAction(routeAction)
@@ -153,6 +159,10 @@ func TestServerConnectionEncodesEveryServerMessage(t *testing.T) {
153159 {name: "welcome", message: welcome, typeID: wire.MsgWelcome, payload: wire.MarshalWelcome(welcome)},
154160 {name: "error", message: errorMessage, typeID: wire.MsgError, payload: wire.MarshalErrorMsg(errorMessage)},
155161 {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},
156166 {name: "detached", message: detached, typeID: wire.MsgDetached, payload: wire.MarshalDetached(detached)},
157167 {name: "pong", message: pong, typeID: wire.MsgPong, payload: wire.MarshalPong(pong)},
158168 {name: "sessions", message: sessions, typeID: wire.MsgSessions, payload: wire.MarshalSessions(sessions)},
@@ -248,7 +258,7 @@ func TestServerConnectionPreservesSendModesCapabilitiesAndErrors(t *testing.T) {
248258 require.ErrorIs(t, connection.SendServerSynchronous(message), sendErr)
249259 require.Equal(t, wire.MsgPong, raw.synchronous.Type)
250260
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()}
252262 require.ErrorIs(t, connection.SendOutput(output), sendErr)
253263 require.Equal(t, wire.MsgOutput, raw.sent.Type)
254264 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
526526 return CommandSpec{Path: "ssh", Args: args}
527527 }
528528
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+
529628 func shellQuote(s string) string {
530629 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
531630 }
@@ -542,6 +641,19 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
542641 return dialContext(ctx, target, session, logger, opts...)
543642 }
544643
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+
545657 // DialContext is like Dial, but the context gates ssh startup. Once the
546658 // transport is returned, its Close method owns the subprocess lifetime; the
547659 // 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
556668 }
557669
558670 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) {
559675 if err := ctx.Err(); err != nil {
560676 return nil, err
561677 }
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+ }
563686 // The caller's context is also the bounded protocol-handshake context and is
564687 // canceled after the first committed publication. Binding it to the command
565688 // 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,
578701 cmd.Stderr = &stderr
579702 if err := cmd.Start(); err != nil {
580703 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)
582705 }
583706 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
584707 }
585708
586- waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
709+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, "")
587710 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
588711 if err := ctx.Err(); err != nil {
589712 _ = transport.Close()
M internal/adapters/term/batchwriter.go +34 -4
@@ -4,6 +4,8 @@ import (
44 "bufio"
55 "io"
66 "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
79 )
810
911 // batchWriter is a thread-safe buffered writer. Writes accumulate in an
@@ -11,13 +13,35 @@ import (
1113 // buffer's capacity passes straight through to the underlying sink
1214 // (bufio.Writer's standard behavior).
1315 type batchWriter struct {
14- mu sync.Mutex
15- bw *bufio.Writer
16+ mu sync.Mutex
17+ 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
1632 }
1733
1834 // newBatchWriter wraps out with a buffer of the given size.
1935 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}
2145 }
2246
2347 // Write buffers p, flushing to the underlying sink only as bufio.Writer
@@ -39,7 +63,13 @@ func (w *batchWriter) WriteString(s string) (int, error) {
3963 func (w *batchWriter) Flush() error {
4064 w.mu.Lock()
4165 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
4373 }
4474
4575 // 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
33 import (
44 "errors"
55 "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
68 )
79
810 // recordingWriter records each Write call it receives, copying the bytes
@@ -32,6 +34,19 @@ func (r *recordingWriter) all() []byte {
3234 return out
3335 }
3436
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+
3550 func TestBatchWriter_BufferedUntilFlush(t *testing.T) {
3651 rw := &recordingWriter{}
3752 bw := newBatchWriter(rw, 64)
@@ -128,3 +143,42 @@ func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) {
128143 t.Fatalf("expected no sink writes, got %d", len(rw.writes))
129144 }
130145 }
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 (
1717 // Compile-time check that Terminal satisfies ports.Terminal.
1818 var _ ports.Terminal = (*Terminal)(nil)
1919
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-
5220 // bufSize is the batched writer's buffer capacity.
5321 const bufSize = 64 * 1024
5422
@@ -58,12 +26,14 @@ const bufSize = 64 * 1024
5826 //
5927 // Zero value is not usable; use New or NewWithFiles.
6028 type Terminal struct {
61- in *os.File
62- out *os.File
63- fd int
64- bw *batchWriter
29+ in *os.File
30+ out *os.File
31+ fd int
32+ bw *batchWriter
33+ observation ports.UIObservationSink
6534
6635 mu sync.Mutex
36+ geometryMu sync.Mutex // serializes winsize reads with observation publication
6737 orig *rawterm.State // saved terminal state; nil when raw mode wasn't entered (or was restored)
6838 entered bool // true between a successful EnterRaw and its restore
6939 rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped
@@ -89,11 +59,18 @@ func New() *Terminal {
8959 // exists so tests can inject a PTY pair (or a plain pipe, for the
9060 // non-tty paths) in place of the process's real stdin/stdout.
9161 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 {
9268 return &Terminal{
93- in: in,
94- out: out,
95- fd: int(in.Fd()),
96- bw: newBatchWriter(out, bufSize),
69+ in: in,
70+ out: out,
71+ fd: int(in.Fd()),
72+ bw: newBatchWriterWithObservation(out, bufSize, observation),
73+ observation: observation,
9774 }
9875 }
9976
@@ -123,7 +100,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
123100 }
124101 t.orig = old
125102
126- if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
103+ if _, err := t.bw.Write(VisualEnterSequence()); err != nil {
127104 t.resetVisualModesDirect()
128105 _ = t.restoreRawLocked()
129106 return nil, fmt.Errorf("term: enter alt screen: %w", err)
@@ -147,7 +124,10 @@ func (t *Terminal) EnterRaw() (func() error, error) {
147124 // harmless. Best effort by construction — the descriptor is usually broken
148125 // too. Must be called with t.mu held.
149126 func (t *Terminal) resetVisualModesDirect() {
150- _, _ = t.out.WriteString(visualRestore)
127+ if t.observation != nil {
128+ t.observation.InvalidateTerminalObservation()
129+ }
130+ _, _ = t.out.Write(VisualRestoreSequence())
151131 }
152132
153133 // makeRestoreLocked returns an idempotent restore closure for the
@@ -170,7 +150,7 @@ func (t *Terminal) restore() error {
170150 t.mu.Lock()
171151 defer t.mu.Unlock()
172152
173- _, werr := t.bw.WriteString(visualRestore)
153+ _, werr := t.bw.Write(VisualRestoreSequence())
174154 ferr := t.bw.Flush()
175155 if werr != nil || ferr != nil {
176156 t.resetVisualModesDirect()
@@ -209,15 +189,21 @@ func (t *Terminal) restoreRawLocked() error {
209189 // Geometry returns the current terminal cell dimensions and optional pixel
210190 // dimensions reported by the controlling terminal.
211191 func (t *Terminal) Geometry() (domain.Geometry, error) {
192+ t.geometryMu.Lock()
193+ defer t.geometryMu.Unlock()
212194 ws, err := rawterm.GetWinsize(t.fd)
213195 if err != nil {
214196 return domain.Geometry{}, fmt.Errorf("term: get size: %w", err)
215197 }
216- return (domain.Geometry{
198+ geometry := (domain.Geometry{
217199 Size: domain.Size{Cols: int(ws.Col), Rows: int(ws.Row)},
218200 PixelWidth: int(ws.Xpixel),
219201 PixelHeight: int(ws.Ypixel),
220- }).NormalizePixels(), nil
202+ }).NormalizePixels()
203+ if t.observation != nil {
204+ t.observation.ObserveTerminalResize(geometry)
205+ }
206+ return geometry, nil
221207 }
222208
223209 // ResizeEvents returns a channel of coalesced terminal sizes, one per
@@ -284,5 +270,9 @@ func (t *Terminal) Out() io.Writer {
284270
285271 // Flush writes any buffered output to the terminal device.
286272 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
288278 }
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
8282 }
8383 payload, err := wire.MarshalOutput(protocol.Output{
8484 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+ },
8590 })
8691 if err != nil {
8792 return
M internal/app/run.go +159 -43
@@ -41,7 +41,6 @@ import (
4141 "github.com/bnema/vev/internal/adapters/shellcmd"
4242 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
4343 "github.com/bnema/vev/internal/adapters/sshstdio"
44- "github.com/bnema/vev/internal/adapters/term"
4544 "github.com/bnema/vev/internal/domain"
4645 "github.com/bnema/vev/internal/logging"
4746 "github.com/bnema/vev/internal/persist"
@@ -71,6 +70,8 @@ const (
7170 kindUDPBootstrap
7271 kindUDPProxy
7372 kindRemotePreview
73+ kindUIDriver
74+ kindUIRemoteCleanup
7475 kindHelp
7576 kindVersion
7677 )
@@ -90,6 +91,10 @@ type command struct {
9091 killDaemon bool
9192 cmd cmdInvocation
9293 remotePreviewPayload string
94+ uiDriver uiDriverOptions
95+ uiObserve bool
96+ uiControl bool
97+ uiSocket string
9398 }
9499
95100 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -120,6 +125,10 @@ usage:
120125 vev kill --all kill all sessions and stop the daemon
121126 vev kill --daemon stop the active vev daemon
122127 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)
123132 vev --help show this help
124133 vev --version show version`
125134
@@ -162,6 +171,46 @@ func parseArgs(args []string) (command, error) {
162171 if len(args) == 0 {
163172 return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil
164173 }
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+ }
165214
166215 switch args[0] {
167216 case "--daemon":
@@ -188,23 +237,30 @@ func parseArgs(args []string) (command, error) {
188237 return command{}, usagef("`_remote-preview` requires one encoded request")
189238 }
190239 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
191245 case "new":
192246 if len(args) < 2 || args[1] == "" {
193247 return command{}, usagef("`new` requires a session name")
194248 }
195- if len(args) > 2 {
196- return command{}, usagef("`new` does not support command overrides")
249+ interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket)
250+ if err != nil {
251+ return command{}, err
197252 }
198253 if err := domain.ValidateSessionName(args[1]); err != nil {
199254 return command{}, err
200255 }
201- return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1]}, nil
256+ return command{kind: kindAttach, intent: protocol.IntentNew, name: args[1], uiObserve: interactive.observe, uiControl: interactive.control, uiSocket: interactive.socket}, nil
202257 case "attach", "a":
203258 if len(args) < 2 || args[1] == "" {
204259 return command{}, usagef("`attach` requires a session name")
205260 }
206- if len(args) > 2 {
207- return command{}, usagef("`attach` accepts exactly one session name or remote target")
261+ interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket)
262+ if err != nil {
263+ return command{}, err
208264 }
209265 cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]}
210266 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
@@ -222,6 +278,9 @@ func parseArgs(args []string) (command, error) {
222278 cmd.intent = protocol.IntentEphemeral
223279 }
224280 }
281+ cmd.uiObserve = interactive.observe
282+ cmd.uiControl = interactive.control
283+ cmd.uiSocket = interactive.socket
225284 return cmd, nil
226285 case "ls", "list":
227286 return parseListArgs(args[1:])
@@ -284,6 +343,10 @@ func dispatch(ctx context.Context, cmd command) error {
284343 return runUDPProxy(ctx, os.Stdout)
285344 case kindRemotePreview:
286345 return runRemotePreview(ctx, cmd.remotePreviewPayload)
346+ case kindUIRemoteCleanup:
347+ return runUIRemoteCleanup(ctx)
348+ case kindUIDriver:
349+ return runUIDriver(ctx, cmd.uiDriver)
287350 case kindList:
288351 return runList(ctx, cmd)
289352 case kindHost:
@@ -293,7 +356,7 @@ func dispatch(ctx context.Context, cmd command) error {
293356 case kindCmd:
294357 return runCmd(ctx, cmd.cmd)
295358 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})
297360 default:
298361 return usagef("unhandled command")
299362 }
@@ -637,7 +700,16 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
637700 if observerCloser != nil {
638701 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
639702 }
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+ }
641713 if err != nil {
642714 return err
643715 }
@@ -785,7 +857,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
785857 })
786858 }
787859
788-const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
860+const (
861+ envRemoteTransport = "VEV_REMOTE_TRANSPORT"
862+ uiRemoteCleanupCommand = "_ui-cleanup"
863+)
789864
790865 func defaultLocalDialer() wire.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
791866
@@ -802,6 +877,12 @@ type runAttachDeps struct {
802877 runClient runClientFunc
803878 createDetached func(context.Context, string) error
804879 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
805886 // clipboard reads a clipboard image on a remote route's Ctrl+V.
806887 // The client retains it across local-to-remote handoffs and only enables
807888 // interception while the active route is remote.
@@ -845,17 +926,24 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error {
845926
846927 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
847928 // 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) {
849930 _, err := remoteTransportModeFromEnv(transport)
850931 if err != nil {
851932 return nil, err
852933 }
853- discovery := daemon.WithRemoteDiscovery(
854- newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
855- newRemoteCatalogClient(),
856- newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
857- )
858- preview := daemon.WithRemotePreview(newRemotePreviewClient())
934+ store := ports.RemoteHostStore(newRemoteHostStore(remoteadapter.HostStorePath(stateDir)))
935+ catalog := ports.RemoteCatalogClient(newRemoteCatalogClient())
936+ cache := ports.RemoteCatalogCache(newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)))
937+ previewClient := ports.RemotePreviewClient(newRemotePreviewClient())
938+ if len(allowlists) > 0 {
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)
859947 return func(d *daemon.Daemon) {
860948 discovery(d)
861949 preview(d)
@@ -932,6 +1020,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
9321020 Intent: target.Intent,
9331021 SessionName: target.Session,
9341022 Remote: true,
1023+ Environment: clientEnvironment(deps.remoteEnvironment, target.Endpoint),
9351024 Origin: protocol.RouteOriginDiscovery,
9361025 OriginKey: target.Endpoint,
9371026 RemoteTarget: selection,
@@ -952,21 +1041,24 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
9521041 return dialErr
9531042 }
9541043 err = runClient(ctx, client.Dependencies{
955- Dialer: sessionwire.NewClientDialer(dialer),
956- Terminal: term.New(),
957- Clock: clock.New(),
958- Clipboard: deps.clipboard,
959- Logger: log,
960- RuntimeObserver: deps.runtimeObserver,
961- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
962- AttachHandoff: handoff,
963- Remote: true,
964- Origin: protocol.RouteOriginRemote,
965- OriginKey: remoteTarget,
1044+ Dialer: sessionwire.NewClientDialer(dialer),
1045+ Terminal: clientTerminal(deps),
1046+ Clock: clientClock(deps),
1047+ DisableCapabilityProbe: deps.disableCapabilityProbe,
1048+ UI: deps.ui,
1049+ Clipboard: deps.clipboard,
1050+ Logger: log,
1051+ RuntimeObserver: deps.runtimeObserver,
1052+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
1053+ AttachHandoff: handoff,
1054+ Remote: true,
1055+ Origin: protocol.RouteOriginRemote,
1056+ OriginKey: remoteTarget,
9661057 }, client.AttachRequest{
9671058 Intent: intent,
9681059 SessionName: name,
9691060 Remote: true,
1061+ Environment: clientEnvironment(deps.remoteEnvironment, remoteTarget),
9701062 Origin: routeOrigin,
9711063 OriginKey: routeOriginKey,
9721064 RemoteTarget: remoteSelection,
@@ -978,17 +1070,19 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
9781070 log.Info("attaching to local session", "intent", intent, "name", name)
9791071 }
9801072 err = runClient(ctx, client.Dependencies{
981- Dialer: sessionwire.NewClientDialer(localDialer()),
982- Terminal: term.New(),
983- Clock: clock.New(),
984- Clipboard: deps.clipboard,
985- Logger: log,
986- RuntimeObserver: deps.runtimeObserver,
987- AttachHandoff: handoff,
988- Remote: false,
989- Origin: protocol.RouteOriginLocal,
990- OriginKey: "local",
991- }, client.AttachRequest{Intent: intent, SessionName: name, Origin: protocol.RouteOriginLocal, OriginKey: "local"})
1073+ Dialer: sessionwire.NewClientDialer(localDialer()),
1074+ Terminal: clientTerminal(deps),
1075+ Clock: clientClock(deps),
1076+ DisableCapabilityProbe: deps.disableCapabilityProbe,
1077+ UI: deps.ui,
1078+ Clipboard: deps.clipboard,
1079+ Logger: log,
1080+ RuntimeObserver: deps.runtimeObserver,
1081+ AttachHandoff: handoff,
1082+ Remote: false,
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"})
9921086 }
9931087
9941088 var handoffErr *client.AttachTargetError
@@ -1029,9 +1123,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
10291123
10301124 const daemonStopTimeout = 2 * time.Second
10311125
1126+var errDaemonNotRunning = errors.New("vev: no daemon running")
1127+
10321128 type localDaemonDialer struct {
1033- dir string
1034- observer ports.SerializedRuntimeObserver
1129+ dir string
1130+ observer ports.SerializedRuntimeObserver
1131+ executable string
1132+ environment []string
10351133 }
10361134
10371135 func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
@@ -1041,7 +1139,11 @@ func (d localDaemonDialer) Dial(ctx context.Context) (wire.Transport, error) {
10411139 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
10421140 }
10431141 }
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)
10451147 }
10461148
10471149 func detachedLocalHello(name, cwd string) protocol.Hello {
@@ -1107,6 +1209,20 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
11071209 }
11081210 }
11091211
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+
11101226 func requestDaemonStop(ctx context.Context) error {
11111227 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
11121228 defer cancel()
@@ -1116,7 +1232,7 @@ func requestDaemonStop(ctx context.Context) error {
11161232 return fmt.Errorf("vev: stopping daemon: %w", err)
11171233 }
11181234 if owner != nil {
1119- return errors.Join(errors.New("vev: no daemon running"), owner.Release())
1235+ return errors.Join(errDaemonNotRunning, owner.Release())
11201236 }
11211237 defer func() { _ = transport.Close() }()
11221238 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)
15851701 printKillSuccess(name, all, daemon)
15861702 return nil
15871703 }
1588- return fmt.Errorf("vev: no daemon running")
1704+ return errDaemonNotRunning
15891705 }
15901706 defer func() { _ = transport.Close() }()
15911707
M internal/app/spawn.go +30 -0
@@ -298,6 +298,36 @@ func withoutPerformanceTraceEnv(env []string) []string {
298298 return filtered
299299 }
300300
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+
301331 // runDaemonLauncher is the intermediate half of the double-fork. It starts
302332 // the long-lived daemon in a new session and exits immediately; realSpawn
303333 // 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 {
182182 // ValidateTabStableID validates the opaque stable tab identity used by
183183 // picker and attachment handoffs.
184184 func ValidateTabStableID(value TabStableID) error {
185- if value == "" || len(value) > 128 || !utf8.ValidString(string(value)) {
186- return errors.New("invalid stable tab ID")
185+ return validateStableID(string(value), "tab")
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)
187196 }
188197 for _, r := range value {
189198 if unicode.IsControl(r) || unicode.IsSpace(r) {
190- return errors.New("invalid stable tab ID")
199+ return fmt.Errorf("invalid stable %s ID", kind)
191200 }
192201 }
193202 return nil
M internal/ports/mocks/recovery_mock.go +18 -18
@@ -657,8 +657,8 @@ func (_c *MockSnapshotRepository_DeleteIncarnation_Call) RunAndReturn(run func(c
657657 }
658658
659659 // 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) {
661- ret := _mock.Called(context1, incarnationID, s, v)
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, checkpointRef)
662662
663663 if len(ret) == 0 {
664664 panic("no return value specified for LoadCheckpoint")
@@ -667,15 +667,15 @@ func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, in
667667 var r0 ports.SnapshotGeneration
668668 var r1 error
669669 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)
671671 }
672672 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)
674674 } else {
675675 r0 = ret.Get(0).(ports.SnapshotGeneration)
676676 }
677677 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)
679679 } else {
680680 r1 = ret.Error(1)
681681 }
@@ -691,12 +691,12 @@ type MockSnapshotRepository_LoadCheckpoint_Call struct {
691691 // - context1 context.Context
692692 // - incarnationID domain.IncarnationID
693693 // - s string
694-// - v ports.CheckpointRef
695-func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, v any) *MockSnapshotRepository_LoadCheckpoint_Call {
696- return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, v)}
694+// - checkpointRef ports.CheckpointRef
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, checkpointRef)}
697697 }
698698
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 {
700700 _c.Call.Run(func(args mock.Arguments) {
701701 var arg0 context.Context
702702 if args[0] != nil {
@@ -729,7 +729,7 @@ func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Return(snapshotGeneration
729729 return _c
730730 }
731731
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 {
733733 _c.Call.Return(run)
734734 return _c
735735 }
@@ -792,8 +792,8 @@ func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 co
792792 }
793793
794794 // ReconcileCheckpoint provides a mock function for the type MockSnapshotRepository
795-func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error {
796- ret := _mock.Called(context1, incarnationID, v)
795+func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, checkpointRef ports.CheckpointRef) error {
796+ ret := _mock.Called(context1, incarnationID, checkpointRef)
797797
798798 if len(ret) == 0 {
799799 panic("no return value specified for ReconcileCheckpoint")
@@ -801,7 +801,7 @@ func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Contex
801801
802802 var r0 error
803803 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)
805805 } else {
806806 r0 = ret.Error(0)
807807 }
@@ -816,12 +816,12 @@ type MockSnapshotRepository_ReconcileCheckpoint_Call struct {
816816 // ReconcileCheckpoint is a helper method to define mock.On call
817817 // - context1 context.Context
818818 // - incarnationID domain.IncarnationID
819-// - v ports.CheckpointRef
820-func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, v any) *MockSnapshotRepository_ReconcileCheckpoint_Call {
821- return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, v)}
819+// - checkpointRef ports.CheckpointRef
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, checkpointRef)}
822822 }
823823
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 {
825825 _c.Call.Run(func(args mock.Arguments) {
826826 var arg0 context.Context
827827 if args[0] != nil {
@@ -849,7 +849,7 @@ func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Return(err error) *Mo
849849 return _c
850850 }
851851
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 {
853853 _c.Call.Return(run)
854854 return _c
855855 }
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() {}
2626 func (RecentRouteSnapshot) clientMessage() {}
2727 func (RouteNavigationFailure) clientMessage() {}
2828 func (SessionCreationFailure) clientMessage() {}
29+func (UIFence) clientMessage() {}
2930
3031 func (Welcome) serverMessage() {}
3132 func (ErrorMsg) serverMessage() {}
@@ -44,3 +45,5 @@ func (RouteNavigationFailure) serverMessage() {}
4445 func (RoutePosition) serverMessage() {}
4546 func (SamePeerSwitchFailure) serverMessage() {}
4647 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
2828 func (id ParkedRouteLeaseID) IsZero() bool { return id == ParkedRouteLeaseID{} }
2929
3030 type NavigationDirective struct {
31- Action NavigationAction
32- LeaseID ParkedRouteLeaseID
31+ CauseActionID uint64
32+ Action NavigationAction
33+ LeaseID ParkedRouteLeaseID
3334 }
3435
3536 type ParkedRouteAction uint8
M internal/protocol/routes.go +2 -0
@@ -229,6 +229,7 @@ type RecentRouteSnapshot struct {
229229 // RouteNavigationAction asks the client to resolve one entry from a specific
230230 // complete snapshot. It carries no session name, endpoint, or credential.
231231 type RouteNavigationAction struct {
232+ CauseActionID uint64
232233 SnapshotGeneration uint64
233234 Key uint64
234235 Generation uint64
@@ -237,6 +238,7 @@ type RouteNavigationAction struct {
237238 // RouteCreateSessionAction asks the client to create a named session through
238239 // one exact route authority from the latest complete snapshot.
239240 type RouteCreateSessionAction struct {
241+ CauseActionID uint64
240242 RequestID uint64
241243 SnapshotGeneration uint64
242244 Key uint64
M internal/protocol/session.go +77 -6
@@ -13,15 +13,16 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 40
16+const Version uint16 = 41
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication.
2020 const HandshakeTimeout = 15 * time.Second
2121
22-// MaxOutputDataLen is the largest decoded terminal byte stream accepted by one
23-// output publication in protocol version 37.
24-const MaxOutputDataLen = (16 << 20) - 55
22+// MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
23+// valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab
24+// 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)
2526
2627 // MaxOutputWindow caps output states sent before client acknowledgement.
2728 const MaxOutputWindow = 8
@@ -122,6 +123,7 @@ func (h Hello) Geometry() domain.Geometry {
122123
123124 type Input struct {
124125 InputSeq uint64
126+ ActionID uint64
125127 Data []byte
126128 }
127129
@@ -185,6 +187,26 @@ type ErrorMsg struct {
185187 Text string
186188 }
187189
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+
188210 type Output struct {
189211 Epoch uint64
190212 Base uint64
@@ -193,10 +215,54 @@ type Output struct {
193215 ViewRevision uint64
194216 Size domain.Size
195217 Full bool
218+ Context *ViewContext
196219 Data []byte
197220 }
198221
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+
199264 type AttachTarget struct {
265+ CauseActionID uint64
200266 RequestID uint64
201267 Endpoint string
202268 Session string
@@ -355,15 +421,20 @@ func ValidateOutput(m Output) error {
355421 return ErrInvalidOutput
356422 }
357423 if m.New == 0 {
358- if m.Base != 0 || m.Full {
424+ if m.Base != 0 || m.Full || m.Context != nil {
359425 return ErrInvalidOutput
360426 }
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)) {
362428 return ErrInvalidOutput
363429 }
364430 if err := ValidateSize(m.Size); err != nil {
365431 return fmt.Errorf("%w: size", ErrInvalidOutput)
366432 }
433+ if m.Context != nil {
434+ if err := m.Context.Validate(); err != nil {
435+ return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
436+ }
437+ }
367438 if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
368439 return ErrInvalidOutput
369440 }
M internal/protocol/session_test.go +8 -3
@@ -10,8 +10,8 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(40), Version)
14- require.Equal(t, (16<<20)-55, MaxOutputDataLen)
13+ require.Equal(t, uint16(41), Version)
14+ require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
1717 func TestHelloSemanticValidation(t *testing.T) {
@@ -44,7 +44,12 @@ func TestHelloSemanticValidation(t *testing.T) {
4444 }
4545
4646 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}
4853 require.NoError(t, ValidateOutput(valid))
4954
5055 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) {
1919 want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1)
2020 want = append(want, make([]byte, 15)...)
2121 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b')
22+ want = append(want, make([]byte, 8)...)
2223 require.Equal(t, want, payload)
2324 decoded, err := UnmarshalAttachTarget(payload)
2425 require.NoError(t, err)
@@ -26,7 +27,7 @@ func TestAttachTargetPreferredTabStrict(t *testing.T) {
2627 assertAllPrefixesFail(t, payload, UnmarshalAttachTarget)
2728 assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget)
2829 malformed := append([]byte(nil), payload...)
29- malformed[len(malformed)-1] = '\n'
30+ malformed[len(malformed)-9] = '\n'
3031 _, err = UnmarshalAttachTarget(malformed)
3132 require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget)
3233
M internal/protocol/wire/frame.go +3 -0
@@ -30,6 +30,7 @@ const (
3030 MsgSamePeerSwitchRequest MsgType = 33
3131 MsgParkedRouteRequest MsgType = 35
3232 MsgSessionCreationFailure MsgType = 37
33+ MsgUIFence MsgType = 39
3334
3435 MsgWelcome MsgType = 16
3536 MsgError MsgType = 17
@@ -49,6 +50,8 @@ const (
4950 MsgSamePeerSwitchFailure MsgType = 34
5051 MsgParkedRouteResponse MsgType = 36
5152 MsgRouteCreateSession MsgType = 38
53+ MsgUIReceipt MsgType = 40
54+ MsgUIViewUpdate MsgType = 41
5255 )
5356
5457 // 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 (
77 )
88
99 func TestProtocolVersion(t *testing.T) {
10- if protocol.Version != 40 {
11- t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
10+ if protocol.Version != 41 {
11+ t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version)
1212 }
1313 }
1414
M internal/protocol/wire/navigation_test.go +1 -1
@@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
153153 {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},
154154 {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},
155155 {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"},
157157 {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},
158158 {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},
159159 {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) {
2929 {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},
3030 } {
3131 t.Run(tt.name, func(t *testing.T) {
32+ context := testViewContext()
33+ tt.output.Context = &context
3234 payload, err := MarshalOutput(tt.output)
3335 if err != nil {
3436 t.Fatal(err)
3537 }
36- if got := payload[outputHeaderLen]; got != tt.wantKind {
38+ if got := payload[outputHeaderLen+len(testViewContextGolden())]; got != tt.wantKind {
3739 t.Fatalf("compression kind = %d, want %d", got, tt.wantKind)
3840 }
3941 decoded, err := UnmarshalOutput(payload)
@@ -52,11 +54,14 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
5254 Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 120, Rows: 40}, Full: true,
5355 Data: bytes.Repeat([]byte("\x1b[31mcompressed\x1b[0m\r\n"), 256),
5456 }
57+ context := testViewContext()
58+ output.Context = &context
5559 payload, err := MarshalOutput(output)
5660 if err != nil {
5761 t.Fatal(err)
5862 }
59- if payload[outputHeaderLen] != outputCompressionZlib {
63+ compressionOffset := outputHeaderLen + len(testViewContextGolden())
64+ if payload[compressionOffset] != outputCompressionZlib {
6065 t.Fatal("test fixture was not compressed")
6166 }
6267
@@ -64,12 +69,12 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
6469 name string
6570 mutate func([]byte)
6671 }{
67- {name: "unknown kind", mutate: func(b []byte) { b[outputHeaderLen] = 99 }},
72+ {name: "unknown kind", mutate: func(b []byte) { b[compressionOffset] = 99 }},
6873 {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))
7075 }},
7176 {name: "compressed incremental", mutate: func(b []byte) {
72- b[outputHeaderLen-1] = 0
77+ b[44] = 0 // Full; byte45 is context presence, not the reset flag.
7378 binary.BigEndian.PutUint64(b[16:24], 2)
7479 binary.BigEndian.PutUint64(b[8:16], 1)
7580 }},
@@ -84,7 +89,7 @@ func TestCompressedOutputRejectsMalformedPayloads(t *testing.T) {
8489 })
8590 }
8691 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)))
8893 if _, err := UnmarshalOutput(trailingCompressed); err == nil {
8994 t.Fatal("UnmarshalOutput accepted trailing compressed bytes")
9095 }
@@ -108,6 +113,8 @@ func BenchmarkMarshalOutput(b *testing.B) {
108113 },
109114 }
110115 for name, output := range fixtures {
116+ context := testViewContext()
117+ output.Context = &context
111118 b.Run(name, func(b *testing.B) {
112119 b.ReportAllocs()
113120 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) {
204204 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
205205 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
206206 0x00, 0x00, 0x00, 0x00,
207+ // CauseActionID.
208+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
207209 }
208210 if !bytes.Equal(got, want) {
209211 t.Fatalf("rich target bytes = %x, want %x", got, want)
@@ -216,3 +218,21 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
216218 t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy)
217219 }
218220 }
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) {
140140 })
141141 },
142142 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
143- policyOffset: 5,
143+ policyOffset: 13,
144144 wantErr: protocol.ErrInvalidAttachTarget,
145145 },
146146 }
@@ -196,7 +196,9 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
196196 }
197197
198198 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}
200202 got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
201203 if !bytes.Equal(got, want) {
202204 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) {
245245 }
246246
247247 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"}
249249 failure := protocol.SessionCreationFailure{RequestID: 5, Code: protocol.RouteFailureUnavailable}
250250 tests := []struct {
251251 name string
@@ -255,7 +255,7 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
255255 unmarshal func([]byte) (any, error)
256256 }{
257257 {
258- name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c65", want: action,
258+ name: "create action", golden: "000000000000000500000000000000090000000000000007000000000000000800076578616d706c650000000000000006", want: action,
259259 marshal: func() ([]byte, error) { return MarshalRouteCreateSessionAction(action) },
260260 unmarshal: func(data []byte) (any, error) {
261261 return UnmarshalRouteCreateSessionAction(data)
@@ -297,10 +297,10 @@ func TestRouteCreateSessionCodecsAreStrict(t *testing.T) {
297297 }
298298
299299 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}
301301 encoded, err := MarshalRouteNavigationAction(action)
302302 require.NoError(t, err)
303- require.Equal(t, "000000000000000900000000000000070000000000000008", hex.EncodeToString(encoded))
303+ require.Equal(t, "0000000000000009000000000000000700000000000000080000000000000006", hex.EncodeToString(encoded))
304304 assertAllPrefixesFail(t, encoded, UnmarshalRouteNavigationAction)
305305 _, err = UnmarshalRouteNavigationAction(append(append([]byte(nil), encoded...), 0))
306306 require.Error(t, err)
M internal/protocol/wire/routes_wire.go +8 -0
@@ -606,6 +606,7 @@ func MarshalRouteNavigationAction(action protocol.RouteNavigationAction) ([]byte
606606 w.putUint64(action.SnapshotGeneration)
607607 w.putUint64(action.Key)
608608 w.putUint64(action.Generation)
609+ w.putUint64(action.CauseActionID)
609610 return w.b, nil
610611 }
611612
@@ -622,6 +623,9 @@ func UnmarshalRouteNavigationAction(b []byte) (protocol.RouteNavigationAction, e
622623 if action.Generation, err = r.getUint64(); err != nil {
623624 return protocol.RouteNavigationAction{}, err
624625 }
626+ if action.CauseActionID, err = r.getUint64(); err != nil {
627+ return protocol.RouteNavigationAction{}, err
628+ }
625629 if err := r.done(); err != nil {
626630 return protocol.RouteNavigationAction{}, err
627631 }
@@ -641,6 +645,7 @@ func MarshalRouteCreateSessionAction(action protocol.RouteCreateSessionAction) (
641645 w.putUint64(action.Key)
642646 w.putUint64(action.Generation)
643647 w.putString(action.SessionName)
648+ w.putUint64(action.CauseActionID)
644649 return w.b, nil
645650 }
646651
@@ -663,6 +668,9 @@ func UnmarshalRouteCreateSessionAction(b []byte) (protocol.RouteCreateSessionAct
663668 if action.SessionName, err = r.getString(); err != nil {
664669 return protocol.RouteCreateSessionAction{}, err
665670 }
671+ if action.CauseActionID, err = r.getUint64(); err != nil {
672+ return protocol.RouteCreateSessionAction{}, err
673+ }
666674 if err := r.done(); err != nil {
667675 return protocol.RouteCreateSessionAction{}, err
668676 }
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")
3131 var errInvalidEnum = errors.New("ports: invalid enum value")
3232
3333 const (
34- outputHeaderLen = 5*8 + 2*2 + 1
34+ outputHeaderLen = 5*8 + 2*2 + 2
3535 outputCompressionHeaderLen = 1 + 4
3636 outputPayloadOverhead = outputHeaderLen + outputCompressionHeaderLen + 4
3737 // outputCompressionThreshold keeps small snapshots on the canonical path.
@@ -669,6 +669,7 @@ func UnmarshalHello(b []byte) (protocol.Hello, error) {
669669 func MarshalInput(m protocol.Input) []byte {
670670 w := payloadWriter{}
671671 w.putUint64(m.InputSeq)
672+ w.putUint64(m.ActionID)
672673 w.putBytes(m.Data)
673674 return w.b
674675 }
@@ -681,7 +682,11 @@ func UnmarshalInput(b []byte) (protocol.Input, error) {
681682 if err != nil {
682683 return protocol.Input{}, err
683684 }
684- return protocol.Input{InputSeq: seq, Data: r.rest()}, nil
685+ 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
685690 }
686691
687692 // MarshalImagePush encodes m into an ImagePush message payload.
@@ -1035,8 +1040,9 @@ func UnmarshalErrorMsg(b []byte) (protocol.ErrorMsg, error) {
10351040 }
10361041
10371042 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
1038-// size/full/compression/decoded-length/data message layout. Compression is
1039-// limited to large, full snapshots and is retained only when it saves bytes.
1043+// size/full/context-presence/context/compression/decoded-length/data layout.
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.
10401046 func MarshalOutput(m protocol.Output) ([]byte, error) {
10411047 if err := protocol.ValidateOutput(m); err != nil {
10421048 return nil, err
@@ -1054,6 +1060,12 @@ func MarshalOutput(m protocol.Output) ([]byte, error) {
10541060 w.putUint16(uint16(m.Size.Cols))
10551061 w.putUint16(uint16(m.Size.Rows))
10561062 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+ }
10571069 w.putUint8(kind)
10581070 w.putUint32(uint32(len(m.Data)))
10591071 w.putLongBytes(data)
@@ -1122,6 +1134,17 @@ func UnmarshalOutput(b []byte) (protocol.Output, error) {
11221134 if m.Full, err = r.getBool(); err != nil {
11231135 return protocol.Output{}, err
11241136 }
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+ }
11251148 if err := protocol.ValidateOutput(m); err != nil {
11261149 return protocol.Output{}, err
11271150 }
@@ -1208,6 +1231,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte {
12081231 marshalExactTargetSection(&w, m.ExactTarget)
12091232 w.putBool(m.SamePeer)
12101233 w.putString(string(m.PreferredTabID))
1234+ w.putUint64(m.CauseActionID)
12111235 return w.b
12121236 }
12131237
@@ -1244,6 +1268,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12441268 if err := probe.skipString(); err != nil {
12451269 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
12461270 }
1271+ if _, err := probe.getUint64(); err != nil {
1272+ return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
1273+ }
12471274 if err := probe.done(); err != nil {
12481275 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
12491276 }
@@ -1276,6 +1303,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) {
12761303 return protocol.AttachTarget{}, err
12771304 }
12781305 m.PreferredTabID = domain.TabStableID(preferredTabID)
1306+ if m.CauseActionID, err = r.getUint64(); err != nil {
1307+ return protocol.AttachTarget{}, err
1308+ }
12791309 if err := r.done(); err != nil {
12801310 return protocol.AttachTarget{}, err
12811311 }
@@ -1724,6 +1754,7 @@ func MarshalNavigationDirective(directive protocol.NavigationDirective) []byte {
17241754 w := payloadWriter{}
17251755 w.putUint8(uint8(directive.Action))
17261756 w.putBytes(directive.LeaseID[:])
1757+ w.putUint64(directive.CauseActionID)
17271758 return w.b
17281759 }
17291760
@@ -1738,10 +1769,14 @@ func UnmarshalNavigationDirective(b []byte) (protocol.NavigationDirective, error
17381769 if err != nil {
17391770 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
17401771 }
1772+ causeActionID, err := r.getUint64()
1773+ if err != nil {
1774+ return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
1775+ }
17411776 if err := r.done(); err != nil {
17421777 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
17431778 }
1744- directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value)}
1779+ directive := protocol.NavigationDirective{Action: protocol.NavigationAction(value), CauseActionID: causeActionID}
17451780 copy(directive.LeaseID[:], lease)
17461781 if MarshalNavigationDirective(directive) == nil {
17471782 return protocol.NavigationDirective{}, protocol.ErrInvalidNavigation
M internal/protocol/wire/wire_final_test.go +20 -12
@@ -12,8 +12,8 @@ import (
1212 )
1313
1414 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
15- if protocol.Version != 40 {
16- t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version)
15+ if protocol.Version != 41 {
16+ t.Fatalf("ProtocolVersion = %d, want 41", protocol.Version)
1717 }
1818 payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1919 if len(payload) < 2 {
@@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2727
2828 func TestFinalHelloGoldenStrict(t *testing.T) {
2929 msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
30- want := append([]byte{0, 40, 2}, make([]byte, 16+8)...)
30+ want := append([]byte{0, 41, 2}, make([]byte, 16+8)...)
3131 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3232 want = append(want, 0, 0)
3333 got := MarshalHello(msg)
@@ -105,9 +105,10 @@ func TestFinalResizeGoldenStrict(t *testing.T) {
105105 }
106106
107107 func TestFinalOutputGoldenStrict(t *testing.T) {
108+ context := testViewContext()
108109 msg := protocol.Output{
109110 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"),
111112 }
112113 want := []byte{
113114 0, 0, 0, 0, 0, 0, 0, 1,
@@ -115,10 +116,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
115116 0, 0, 0, 0, 0, 0, 0, 7,
116117 0, 0, 0, 0, 0, 0, 0, 8,
117118 0, 0, 0, 0, 0, 0, 0, 9,
118- 0, 80, 0, 24, 1,
119- 0, 0, 0, 0, 2,
120- 0, 0, 0, 2, 'o', 'k',
119+ 0, 80, 0, 24, 1, 1,
121120 }
121+ want = append(want, testViewContextGolden()...)
122+ want = append(want, 0, 0, 0, 0, 2, 0, 0, 0, 2, 'o', 'k')
122123 got, err := MarshalOutput(msg)
123124 if err != nil {
124125 t.Fatalf("MarshalOutput() error = %v", err)
@@ -135,11 +136,12 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
135136 }
136137
137138 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}
139141 for _, bad := range []protocol.Output{
140142 {},
141- {Epoch: 1, Base: 0, New: 1, Size: valid.Size},
142- {Epoch: 1, Base: 0, New: 1, Full: true},
143+ {Epoch: 1, Base: 0, New: 1, Size: valid.Size, Context: &context},
144+ {Epoch: 1, Base: 0, New: 1, Full: true, Context: &context},
143145 } {
144146 if got, err := MarshalOutput(bad); err == nil || got != nil {
145147 t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err)
@@ -158,7 +160,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
158160 {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
159161 {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
160162 {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+ }},
162167 } {
163168 t.Run(tt.name, func(t *testing.T) {
164169 bad := append([]byte(nil), payload...)
@@ -197,6 +202,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
197202 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
198203 msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}
199204 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)...)
200206 got := MarshalAttachTarget(msg)
201207 if !bytes.Equal(got, want) {
202208 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -213,6 +219,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
213219 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
214220 }
215221 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)...)
216223 if !bytes.Equal(localPayload, wantLocal) {
217224 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
218225 }
@@ -249,6 +256,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) {
249256 want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...)
250257 want = append(want, make([]byte, 15)...)
251258 want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0)
259+ want = append(want, make([]byte, 8)...)
252260 if !bytes.Equal(payload, want) {
253261 t.Fatalf("exact target bytes = %x, want %x", payload, want)
254262 }
@@ -302,7 +310,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
302310 name: "attach target same-peer boolean",
303311 payload: func() []byte {
304312 b := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach})
305- b[len(b)-1] = 2
313+ b[len(b)-11] = 2
306314 return b
307315 }(),
308316 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) {
136136 }
137137 got := MarshalHello(msg)
138138 want := []byte{
139- 0x00, 0x28, 0x00, // version, intent, first client ID byte
139+ 0x00, 0x29, 0x00, // version, intent, first client ID byte
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
142142 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -217,8 +217,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
217217 msg protocol.Input
218218 want []byte
219219 }{
220- {name: "data", msg: protocol.Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
221- {name: "empty", msg: protocol.Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
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: make([]byte, 16)},
222222 }
223223
224224 for _, tt := range tests {
@@ -238,7 +238,7 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
238238 }
239239
240240 full := MarshalInput(tests[0].msg)
241- assertAllPrefixesFail(t, full[:8], UnmarshalInput)
241+ assertAllPrefixesFail(t, full[:16], UnmarshalInput)
242242 }
243243
244244 func TestImagePushGoldenAndRoundTrip(t *testing.T) {
@@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
367367 }
368368 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
369369 require.Equal(t, want, MarshalTheme(cleared))
370- require.Equal(t, uint16(40), protocol.Version)
370+ require.Equal(t, uint16(41), protocol.Version)
371371 }
372372
373373 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
528528 name: "minimal",
529529 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"},
530530 want: []byte{
531- 0x00, 0x28,
531+ 0x00, 0x29,
532532 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
533533 0x00, // attached
534534 0x00, // self
@@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
551551 JSON: true,
552552 },
553553 want: []byte{
554- 0x00, 0x28,
554+ 0x00, 0x29,
555555 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
556556 0x00, // attached
557557 0x01, // self
@@ -682,8 +682,18 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
682682 }
683683
684684 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")}
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}
685+ context := testViewContext()
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')
687697
688698 got, err := MarshalOutput(msg)
689699 if err != nil {
@@ -989,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989999 name: "attached",
9901000 msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
9911001 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,
9931003 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
9941004 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
9951005 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -999,7 +1009,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
9991009 name: "control",
10001010 msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"},
10011011 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,
10031013 0x00, 0x02, 'l', 's', 0x00, 0x00,
10041014 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
10051015 },
M internal/testutil/replaytest/replay.go +5 -0
@@ -13,6 +13,11 @@ import (
1313 )
1414
1515 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+ }
1621 payload, err := wire.MarshalOutput(m)
1722 if err != nil {
1823 panic(err)
M internal/usecase/client/client.go +349 -81
@@ -161,6 +161,7 @@ type Dependencies struct {
161161 // DisableCapabilityProbe skips active outer-terminal discovery for headless
162162 // embedders and deterministic tests. Interactive clients leave it false.
163163 DisableCapabilityProbe bool
164+ UI *UI
164165 Clipboard ports.ClipboardReader
165166 Logger *slog.Logger
166167 RuntimeObserver ports.SerializedRuntimeObserver
@@ -181,16 +182,20 @@ type Dependencies struct {
181182 // client-only presentation metadata. RemoteTarget is an optional exact picker
182183 // handoff and is serialized into Hello only when present.
183184 type AttachRequest struct {
184- Intent uint8
185- SessionName string
186- Remote bool
187- Origin protocol.RouteOrigin
188- OriginKey string
189- RemoteTarget *domain.RemoteSessionTarget
190- ExactTarget *protocol.ExactSessionTarget
191- PreferredTabID domain.TabStableID
192- HostLabel string
193- EnvironmentPolicy protocol.EnvironmentPolicy
185+ Intent uint8
186+ SessionName string
187+ Remote bool
188+ Origin protocol.RouteOrigin
189+ OriginKey string
190+ RemoteTarget *domain.RemoteSessionTarget
191+ ExactTarget *protocol.ExactSessionTarget
192+ PreferredTabID domain.TabStableID
193+ HostLabel string
194+ 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
194199 NavigationCapabilities protocol.NavigationCapabilities
195200 StartupOverlay protocol.StartupOverlay
196201 }
@@ -217,6 +222,7 @@ func bindAttachHandoff(target protocol.AttachTarget, source attachRoute) *attach
217222 }
218223
219224 type Runner struct {
225+ ui *UI
220226 dialer ports.ClientDialer
221227 term ports.Terminal
222228 clock ports.Clock
@@ -246,6 +252,7 @@ func NewRunner(deps Dependencies) *Runner {
246252 log = slog.Default()
247253 }
248254 return &Runner{
255+ ui: deps.UI,
249256 dialer: deps.Dialer,
250257 term: deps.Terminal,
251258 clock: deps.Clock,
@@ -369,6 +376,21 @@ func (r *Runner) rememberKittyDirectGraphics(value bool) bool {
369376 return value
370377 }
371378
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+
372394 func validateAttachRequest(request AttachRequest) error {
373395 if request.Origin != 0 {
374396 if err := request.Origin.Validate(); err != nil {
@@ -426,6 +448,15 @@ func validateAttachRequest(request AttachRequest) error {
426448 // above attach attempts so raw mode remains active while a live client process
427449 // redials a lost link.
428450 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+ }
429460 request = cloneAttachRequest(request)
430461 if request.Origin == 0 {
431462 request.Origin = r.origin
@@ -517,6 +548,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
517548 }
518549 }
519550 }()
551+ if r.ui != nil {
552+ defer r.ui.status(ports.UIStatusDetached)
553+ }
520554 remote := request.Remote || r.remote
521555 reconnect := &reconnectUI{
522556 term: r.term,
@@ -632,6 +666,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
632666 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
633667 transport, err := boundedDialWithTransition(handshakeCtx, dialer, transition)
634668 if err != nil {
669+ if r.ui != nil {
670+ r.ui.failHandoff()
671+ }
635672 err = handshakeContextError(ctx, timedOut, err)
636673 finishHandshake()
637674 if killedSelection != nil {
@@ -728,6 +765,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
728765 request: attemptRequest,
729766 resumeToken: resumeToken,
730767 routeNavigationSelection: routeNavigationSelection,
768+ routeNavigationAction: cloneRouteNavigationAction(routeNavigationAction),
731769 killedSelection: killedSelection,
732770 clientID: processClientID,
733771 milestones: &ms,
@@ -744,6 +782,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
744782 }).run(ctx)
745783 stopHandshakeTransport()
746784 finishHandshake()
785+ if result.err != nil && r.ui != nil {
786+ r.ui.failHandoff()
787+ }
747788 if result.welcomed {
748789 backoff = defaultReconnectBackoff.initial
749790 }
@@ -1264,6 +1305,7 @@ type attachAttempt struct {
12641305 request AttachRequest
12651306 resumeToken uint64
12661307 routeNavigationSelection *routeNavigationSelection
1308+ routeNavigationAction *protocol.RouteNavigationAction
12671309 killedSelection *killedRouteSelection
12681310 clientID [16]byte
12691311 milestones *milestones
@@ -1433,7 +1475,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
14331475 TrueColor: trueColor,
14341476 KittyDirectGraphics: kittyDirectGraphics,
14351477 MaxOutputInFlight: requestedOutputWindow(transport),
1436- Env: os.Environ(),
1478+ Env: attachEnvironment(request.Environment),
14371479 RemoteTarget: request.RemoteTarget,
14381480 ExactTarget: exactTarget,
14391481 PreferredTabID: request.PreferredTabID,
@@ -1569,6 +1611,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15691611 controlCh := make(chan protocol.ClientMessage, 1)
15701612 barrierCh := make(chan chan struct{})
15711613 inputGate := newSamePeerInputGate()
1614+ inputGate.ui = a.runner.ui
15721615 ackQueue := newCumulativeAckQueue()
15731616 sendErrCh := make(chan error, 1)
15741617 senderStarted := false
@@ -1678,6 +1721,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
16781721 }
16791722 awaitingReconnectReset := false
16801723 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
16811734 outputResetRequested := false
16821735 transitionWaitingFull := false
16831736 var samePeerSwitch *samePeerSwitchPending
@@ -1737,6 +1790,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17371790 }
17381791 foregroundCtx, foregroundCancel := context.WithCancel(loopCtx)
17391792 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
17401800 stdinDone := make(chan struct{})
17411801 resizeDone := make(chan struct{})
17421802 sendLease := newForegroundSendLease()
@@ -1747,7 +1807,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
17471807 foregroundCancel()
17481808 cancel()
17491809 }
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()
17511811 }()
17521812 go func() {
17531813 defer close(resizeDone)
@@ -2037,6 +2097,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20372097 }
20382098 continue
20392099 }
2100+ if ui != nil {
2101+ publishUIStatus(ports.UIStatusReconnecting)
2102+ }
20402103 stage := stageForLinkState(ev.State)
20412104 if reconnect.remote && reconnect.showing && reconnect.stage != stage {
20422105 reconnect.stage = stage
@@ -2056,6 +2119,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20562119 return welcomedResult(errLinkOffline)
20572120 }
20582121 case event := <-paletteEvents:
2122+ if event.kind == paletteEventUIBatchEnd {
2123+ close(event.batchApplied)
2124+ continue
2125+ }
20592126 if event.kind == paletteEventScheme {
20602127 retained := themeState.update(func(current *protocol.Theme) {
20612128 current.SchemeKnown = true
@@ -2063,12 +2130,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20632130 })
20642131 actions := coordinator.start(retained, true)
20652132 activeGeneration.Store(uint64(coordinator.current.id))
2066- if err := processPaletteActions(actions); err != nil {
2133+ err := processPaletteActions(actions)
2134+ event.applied()
2135+ if err != nil {
20672136 return welcomedResult(err)
20682137 }
20692138 continue
20702139 }
2071- if err := processPaletteActions(coordinator.handle(event)); err != nil {
2140+ err := processPaletteActions(coordinator.handle(event))
2141+ event.applied()
2142+ if err != nil {
20722143 return welcomedResult(err)
20732144 }
20742145 case r := <-recvCh:
@@ -2099,7 +2170,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
20992170 if parkedWaitingFull && !o.Full {
21002171 return welcomedResult(errors.New("vev: parked route resumed without an authoritative full output"))
21012172 }
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+ }
21022186 if _, werr := term.Out().Write(o.Data); werr != nil {
2187+ if uiOutput != nil {
2188+ uiOutput.EndOutput(false)
2189+ }
21032190 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
21042191 }
21052192 if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
@@ -2108,15 +2195,33 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
21082195 if reconnect.showing && reconnect.remote {
21092196 geometry, serr := term.Geometry()
21102197 if serr != nil {
2198+ if uiOutput != nil {
2199+ uiOutput.EndOutput(false)
2200+ }
21112201 return welcomedResult(fmt.Errorf("vev: reading terminal geometry for reconnect redraw: %w", serr))
21122202 }
21132203 if rerr := reconnect.redraw(geometry.Size); rerr != nil {
2204+ if uiOutput != nil {
2205+ uiOutput.EndOutput(false)
2206+ }
21142207 return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
21152208 }
21162209 } else if ferr := term.Flush(); ferr != nil {
2210+ if uiOutput != nil {
2211+ uiOutput.EndOutput(false)
2212+ }
21172213 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
21182214 }
2215+ if uiOutput != nil {
2216+ uiOutput.EndOutput(true)
2217+ }
21192218 outputState = nextState
2219+ if ui != nil && o.New != 0 {
2220+ ui.published(uiGeneration)
2221+ if o.Full {
2222+ ui.destinationFull(uiGeneration)
2223+ }
2224+ }
21202225 if transition != nil && transition.active && (!transitionWaitingFull || o.Full) {
21212226 transitionWaitingFull = false
21222227 transition.stop()
@@ -2138,7 +2243,38 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
21382243 log.Debug("received first output")
21392244 endHandshake()
21402245 }
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+ }
21412273 case protocol.AttachTarget:
2274+ if ui != nil {
2275+ ui.follow(uiGeneration, message.CauseActionID)
2276+ publishUIStatus(ports.UIStatusTransitioning)
2277+ }
21422278 target := message
21432279 if transientPicker {
21442280 handoff := welcomedResult(nil)
@@ -2164,6 +2300,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
21642300 return welcomedResult(errors.New("vev: invalid same-peer switch"))
21652301 }
21662302 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+ }
21672309 inputGate.setPaused(true)
21682310 select {
21692311 case controlCh <- switchRequest:
@@ -2219,6 +2361,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
22192361 }
22202362 }
22212363 case protocol.NavigationDirective:
2364+ if ui != nil {
2365+ ui.follow(uiGeneration, message.CauseActionID)
2366+ publishUIStatus(ports.UIStatusTransitioning)
2367+ }
22222368 directive := message
22232369 datagramRoute := transport.Capabilities().PreferredOutputWindow == 1
22242370 if directive.Action == protocol.NavigationOpenHomePicker && a.openHomePicker != nil && datagramRoute {
@@ -2251,6 +2397,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
22512397 }
22522398 continue
22532399 }
2400+ if ui != nil {
2401+ ui.follow(uiGeneration, action.CauseActionID)
2402+ publishUIStatus(ports.UIStatusTransitioning)
2403+ }
22542404 result := welcomedResult(nil)
22552405 result.routeCreateAction = &action
22562406 return result
@@ -2272,6 +2422,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
22722422 if noOp {
22732423 continue
22742424 }
2425+ if ui != nil {
2426+ ui.follow(uiGeneration, action.CauseActionID)
2427+ publishUIStatus(ports.UIStatusTransitioning)
2428+ }
22752429 navigation := welcomedResult(nil)
22762430 navigation.routeAction = &action
22772431 return navigation
@@ -2301,9 +2455,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
23012455 case protocol.SamePeerSwitchFailure:
23022456 failure := message
23032457 if samePeerSwitch != nil && failure.RequestID == samePeerSwitch.requestID {
2458+ if ui != nil {
2459+ ui.failHandoff()
2460+ }
23042461 samePeerSwitch = nil
23052462 inputGate.setPaused(false)
2306- if transition != nil && transition.showing {
2463+ if ui != nil || transition != nil && transition.showing {
23072464 transitionWaitingFull = true
23082465 if !outputResetRequested {
23092466 select {
@@ -2329,6 +2486,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
23292486 case protocol.RouteNavigationFailure:
23302487 failure := message
23312488 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+ }
23322492 case protocol.Detached:
23332493 d := message
23342494 ms.detached = true
@@ -2427,6 +2587,9 @@ func (q *cumulativeAckQueue) take() (uint64, uint64) {
24272587 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) {
24282588 defer log.Debug("sender pump exited")
24292589 send := func(message protocol.ClientMessage) bool {
2590+ if inputGate.discardRetiredUI(message) {
2591+ return true
2592+ }
24302593 if err := transport.SendClient(message); err != nil {
24312594 select {
24322595 case errCh <- err:
@@ -2562,9 +2725,32 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.C
25622725
25632726 // terminalReadResult is copied by terminalInputPump before the next Read so
25642727 // 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+
25652736 type terminalReadResult struct {
2566- data []byte
2567- err error
2737+ data []byte
2738+ 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
25682754 }
25692755
25702756 // terminalInputPump is the sole consumer of the caller-owned terminal reader.
@@ -2573,8 +2759,9 @@ type terminalReadResult struct {
25732759 // after Run exits; suspend drops any result received while inactive and never
25742760 // closes caller-owned stdin. Reconnects and later runs never add another reader.
25752761 type terminalInputPump struct {
2576- in io.Reader
2577- done chan struct{}
2762+ in io.Reader
2763+ done chan struct{}
2764+ automation chan terminalAutomationRequest
25782765
25792766 mu sync.Mutex
25802767 // residual holds scanner bytes that were read but not delivered when an
@@ -2605,6 +2792,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
26052792 return &terminalInputPump{
26062793 in: in,
26072794 done: make(chan struct{}),
2795+ automation: make(chan terminalAutomationRequest),
26082796 activation: 1,
26092797 active: true,
26102798 ready: make(chan struct{}, 1),
@@ -2913,6 +3101,7 @@ type stdinPump struct {
29133101 in io.Reader // compatibility for isolated scanner tests
29143102 input *terminalInputPump
29153103 consumer uint64
3104+ uiGeneration uint64
29163105 out chan<- protocol.ClientMessage
29173106 clock ports.Clock
29183107 clipboard ports.ClipboardReader
@@ -2930,6 +3119,16 @@ func (p *stdinPump) run() {
29303119 var scanner theme.Scanner
29313120 var markers paletteMarkerScanner
29323121 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+ }()
29333132 var sendOK atomic.Bool
29343133 var undeliveredMu sync.Mutex
29353134 var undelivered []byte
@@ -2951,11 +3150,16 @@ func (p *stdinPump) run() {
29513150 }
29523151 })
29533152 }
3153+ var pendingEvents atomic.Int64
29543154 sendEvent := func(event paletteGenerationEvent) bool {
29553155 if !sendOK.Load() {
29563156 return false
29573157 }
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 {
29593163 select {
29603164 case p.paletteEvents <- event:
29613165 if p.afterPaletteEvent != nil {
@@ -2967,13 +3171,20 @@ func (p *stdinPump) run() {
29673171 return false
29683172 }
29693173 })
3174+ if !ok {
3175+ event.applied()
3176+ }
3177+ return ok
29703178 }
29713179 coalescer := newPasteCoalescer(p.clock, func(data []byte) {
29723180 if len(data) == 0 {
29733181 return
29743182 }
29753183 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...)}) {
29773188 undeliveredMu.Lock()
29783189 undelivered = append(undelivered, data...)
29793190 undeliveredMu.Unlock()
@@ -3014,7 +3225,7 @@ func (p *stdinPump) run() {
30143225 // that undecided suffix; the rest of its source read has already been
30153226 // delivered and must not be replayed by the next attempt.
30163227 defer func() {
3017- if p.ctx.Err() != nil {
3228+ if p.ctx.Err() != nil && !automated {
30183229 // Closing flushes any bracketed-paste prefix or buffer through the
30193230 // coalescer's emit callback. Failed ordinary-input deliveries are
30203231 // retained ahead of a trailing undecided marker prefix, matching their
@@ -3067,86 +3278,143 @@ func (p *stdinPump) run() {
30673278 defer disarmMarkerDeadline()
30683279
30693280 for {
3281+ var result terminalReadResult
3282+ var batch *terminalAutomationRequest
30703283 select {
30713284 case <-p.ctx.Done():
30723285 return
30733286 case <-markerTimerC:
30743287 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)
30753309 case <-input.ready:
3076- result, ok := input.take(p.ctx, consumer)
3310+ var ok bool
3311+ result, ok = input.take(p.ctx, consumer)
30773312 if !ok {
30783313 if p.ctx.Err() != nil {
30793314 return
30803315 }
30813316 continue
30823317 }
3083- if p.afterInputTake != nil {
3084- p.afterInputTake()
3085- }
3086- // The lease is not acknowledged until scanner delivery completes.
3087- // This second check closes the cancellation window after take.
3088- if p.ctx.Err() != nil {
3089- return
3090- }
3091- disarmMarkerDeadline()
3092- // All scanner callbacks from this Read must retain this one generation.
3093- // A scheme notification can start its replacement before a later byte in
3094- // the same read is scanned; reloading there would misclassify an old
3095- // completion marker as the replacement's drain response.
3096- readGeneration := paletteGenerationID(p.activeGeneration.Load())
3097- if len(result.data) > 0 {
3098- scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {
3099- kindEvent := paletteEventForeground
3100- if kind == 11 {
3101- kindEvent = paletteEventBackground
3102- }
3103- sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
3104- }, func(slot int, rgb renderer.RGB) {
3105- // Scanner implementations are independently testable; retain the
3106- // protocol boundary here too so a malformed implementation cannot
3107- // wrap a negative or oversized int into an ANSI palette slot.
3108- slot8, ok := paletteSlot(slot)
3109- if !ok {
3110- return
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)
3318+ }
3319+ if p.afterInputTake != nil {
3320+ p.afterInputTake()
3321+ }
3322+ // The lease is not acknowledged until scanner delivery completes.
3323+ // This second check closes the cancellation window after take.
3324+ if p.ctx.Err() != nil {
3325+ return
3326+ }
3327+ disarmMarkerDeadline()
3328+ // All scanner callbacks from this Read must retain this one generation.
3329+ // A scheme notification can start its replacement before a later byte in
3330+ // the same read is scanned; reloading there would misclassify an old
3331+ // completion marker as the replacement's drain response.
3332+ readGeneration := paletteGenerationID(p.activeGeneration.Load())
3333+ if len(result.data) > 0 {
3334+ scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {
3335+ kindEvent := paletteEventForeground
3336+ if kind == 11 {
3337+ kindEvent = paletteEventBackground
3338+ }
3339+ sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
3340+ }, func(slot int, rgb renderer.RGB) {
3341+ // Scanner implementations are independently testable; retain the
3342+ // protocol boundary here too so a malformed implementation cannot
3343+ // wrap a negative or oversized int into an ANSI palette slot.
3344+ slot8, ok := paletteSlot(slot)
3345+ if !ok {
31263346 return
31273347 }
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
31283363 }
3129- if markers.hasPendingPrefix() {
3130- armMarkerDeadline()
3131- }
3132- if result.err != nil {
3133- disarmMarkerDeadline()
3134- markers.flush(sink)
3364+ }
3365+ if batch != nil {
3366+ scanner.EndBatch(func(data []byte) {
3367+ markers.scan(data, sink, func() { sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker}) })
3368+ })
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 {
31353377 select {
3136- case p.out <- protocol.Detach{}:
3378+ case <-applied:
3379+ batchComplete = true
31373380 case <-p.ctx.Done():
3381+ ok = false
31383382 }
3139- p.cancel()
3140- return
31413383 }
3142- // Commit every callback from this raw read before observing a
3143- // concurrent cancellation. Any remaining marker prefix is retained
3144- // by the deferred handoff above, so reconnects never duplicate the
3145- // delivered bytes or lose a standalone Escape.
3146- input.ack(consumer)
3147- if p.ctx.Err() != nil {
3384+ if ok && batchSent.Load() {
3385+ ok = send(protocol.UIFence{ActionID: result.actionID})
3386+ } else if ok && batch.owner != nil {
3387+ batch.owner.completeLocal(result.generation, result.actionID)
3388+ }
3389+ batch.dispatched <- batchComplete
3390+ activeBatch = nil
3391+ if !ok {
31483392 return
31493393 }
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
31503418 }
31513419 }
31523420 }
M internal/usecase/client/output_state.go +52 -21
@@ -1,52 +1,83 @@
11 package client
22
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+)
47
58 // outputApplyState is the client-side dependency chain for one attachment.
6-// State-bearing frames are accepted only when their epoch, base, and view
7-// revision continue the last frame; a new epoch must carry a full reset.
9+// Cells and captured semantic context are admitted together and committed only
10+// after the corresponding terminal transaction succeeds.
811 type outputApplyState struct {
912 epoch uint64
1013 state uint64
1114 viewRevision uint64
1215 initialized bool
16+ context protocol.ViewContext
1317 }
1418
1519 func (s outputApplyState) next(output protocol.Output) (outputApplyState, bool) {
1620 if output.Epoch == 0 {
1721 return outputApplyState{}, false
1822 }
19- // State-independent terminal side effects are ordered on the same transport
20- // as ordinary output but deliberately do not advance or acknowledge the
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.
23+ // Ordered side effects cross replay reset gates, but cannot advance or
24+ // overwrite committed state. Handoff cleanup must not invent a destination.
2425 if output.New == 0 {
25- if output.Base != 0 || output.Full {
26+ if output.Base != 0 || output.Full || output.Context != nil {
2627 return outputApplyState{}, false
2728 }
2829 return s, true
2930 }
31+ if output.Context == nil || output.Context.Validate() != nil || output.Context.Publication <= s.context.Publication {
32+ return outputApplyState{}, false
33+ }
3034 if !s.initialized {
3135 if output.Base != 0 || !output.Full {
3236 return outputApplyState{}, false
3337 }
34- return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
35- }
36- if output.Epoch < s.epoch {
37- return outputApplyState{}, false
38- }
39- if output.Epoch == s.epoch {
40- if output.ViewRevision != s.viewRevision {
38+ } else {
39+ if output.Epoch < s.epoch {
4140 return outputApplyState{}, false
4241 }
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 {
4450 return outputApplyState{}, false
4551 }
46- return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
4752 }
48- if !output.Full || output.Base != 0 {
49- return outputApplyState{}, false
53+ return outputApplyState{
54+ 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,
5082 }
51- return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
5283 }
M internal/usecase/client/output_state_test.go +10 -0
@@ -29,6 +29,16 @@ func TestOutputApplyStateTransitions(t *testing.T) {
2929 }
3030 for _, tt := range tests {
3131 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+ }
3242 got, accepted := tt.state.next(tt.output)
3343 if accepted != tt.accepted {
3444 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 @@
11 package client
22
33 import (
4+ "sync/atomic"
45 "time"
56
67 renderer "github.com/bnema/vev-vt"
@@ -53,14 +54,23 @@ const (
5354 paletteEventBackground
5455 paletteEventPalette
5556 paletteEventScheme
57+ paletteEventUIBatchEnd
5658 )
5759
5860 type paletteGenerationEvent struct {
59- id paletteGenerationID
60- kind paletteGenerationEventKind
61- rgb renderer.RGB
62- slot uint8
63- light bool
61+ id paletteGenerationID
62+ kind paletteGenerationEventKind
63+ rgb renderer.RGB
64+ slot uint8
65+ 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+ }
6474 }
6575
6676 // 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 {
158158 return c.buffering || len(c.pending) > 0
159159 }
160160
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+
161187 // Close stops any pending timers and their goroutines. Callers which hand input
162188 // to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
163189 func (c *pasteCoalescer) Close() {
M internal/usecase/client/same_peer_switch.go +33 -3
@@ -3,7 +3,9 @@ package client
33 import (
44 "context"
55 "sync"
6+ "sync/atomic"
67
8+ "github.com/bnema/vev/internal/ports"
79 "github.com/bnema/vev/internal/protocol"
810 )
911
@@ -11,9 +13,11 @@ import (
1113 // not drop or reparse bytes: once the daemon commits, queued input follows the
1214 // attachment to the target; a typed pre-commit failure releases it to source.
1315 type samePeerInputGate struct {
14- mu sync.Mutex
15- paused bool
16- changed chan struct{}
16+ ui *UI
17+ retiredUI atomic.Uint64
18+ mu sync.Mutex
19+ paused bool
20+ changed chan struct{}
1721 // afterInputHeld is a deterministic test synchronization seam.
1822 afterInputHeld func()
1923 }
@@ -40,6 +44,32 @@ func (g *samePeerInputGate) setPaused(paused bool) {
4044 g.mu.Unlock()
4145 }
4246
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+
4373 func (g *samePeerInputGate) snapshot() (bool, <-chan struct{}) {
4474 if g == nil {
4575 return false, nil
M internal/usecase/client/session_connection.go +3 -0
@@ -76,6 +76,9 @@ func (c *SessionConnection) AttachRequest() AttachRequest {
7676 // cloneAttachRequest keeps the immutable request independent from transport
7777 // ownership while protecting the exact remote target from caller mutation.
7878 func cloneAttachRequest(request AttachRequest) AttachRequest {
79+ if request.Environment != nil {
80+ request.Environment = append(make([]string, 0, len(request.Environment)), request.Environment...)
81+ }
7982 if request.RemoteTarget != nil {
8083 target := *request.RemoteTarget
8184 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 @@
11 package client_test
22
33 import (
4+ "github.com/bnema/vev/internal/domain"
45 "github.com/bnema/vev/internal/protocol"
56 "github.com/bnema/vev/internal/protocol/wire"
67 )
78
89 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+ }
918 payload, err := wire.MarshalOutput(m)
1019 if err != nil {
1120 panic(err)
M internal/usecase/client/wire_test_helpers_test.go +6 -0
@@ -6,6 +6,12 @@ import (
66 )
77
88 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+ }
915 payload, err := wire.MarshalOutput(m)
1016 if err != nil {
1117 panic(err)
M internal/usecase/daemon/attachment_effect_gate.go +21 -1
@@ -86,7 +86,10 @@ type attachmentEffect struct {
8686 lifecycle *attachmentLifecycle
8787 actionDaemon *Daemon
8888 action string
89- ended atomic.Bool
89+ // 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
9093 }
9194
9295 func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) {
@@ -198,6 +201,23 @@ func (t *attachmentEffect) sendControl(message protocol.ServerMessage) error {
198201 if !t.current() || !t.ac.transportSnapshotCurrent(t.transport) || !t.beginTransportSend(t.transport) {
199202 return errAttachmentTransition
200203 }
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+ }
201221 err := t.transport.transport.SendServer(message)
202222 if err != nil {
203223 t.reportTransportFailure(t.transport)
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
@@ -765,7 +765,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
765765 defer release2()
766766 defer release3()
767767 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{
769769 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
770770 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
771771 }}
M internal/usecase/daemon/attachment_output.go +84 -9
@@ -32,6 +32,8 @@ type attachmentOutput struct {
3232 maxOutstandingAtomic atomic.Uint64
3333 forceSnapshot bool
3434 initialized bool
35+ viewPublication uint64
36+ lastViewContext protocol.ViewContext
3537 lastCursor cursorOut
3638 lastRoutePosition protocol.RoutePosition
3739 graphicsOutput *graphicsOutputState
@@ -138,6 +140,9 @@ func (o *attachmentOutput) prepareFrame(d *Daemon, state *capturedRenderState, f
138140 if err != nil {
139141 return nil, err
140142 }
143+ context := state.viewContext()
144+ ansi.context = &context
145+ ansi.viewRevision = state.view.revision
141146 cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
142147 graphicsReset := reset || o.forceSnapshot || !o.initialized
143148 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
172177 return nil
173178 }
174179
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+
175192 func (p *preparedAttachmentOutput) commitNoSend() {
176193 if p == nil || p.ansi == nil {
177194 return
@@ -244,11 +261,15 @@ type preparedOutput struct {
244261 generation uint64
245262 connectionGeneration uint64
246263 viewRevision uint64
247- size domain.Size
248- data []byte
249- reset bool
250- attempted bool
251- sent bool
264+ context *protocol.ViewContext
265+ // boundary is the exact accepted semantic publication, not mutable stream
266+ // state that a concurrent view rebase could replace before receipt send.
267+ boundary protocol.UIReceipt
268+ size domain.Size
269+ data []byte
270+ reset bool
271+ attempted bool
272+ sent bool
252273 }
253274
254275 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
304325 if p.reset {
305326 base = 0
306327 }
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)
308338 if err != nil {
309339 p.stream.forceSnapshot = true
310340 return err
@@ -314,6 +344,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Ou
314344 return err
315345 }
316346 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+ }
317352 p.stream.next = p.next
318353 p.stream.publishOutstanding()
319354 p.sent = true
@@ -334,6 +369,46 @@ func (p *preparedOutput) commitNoSend() {
334369 p.sent = true
335370 }
336371
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+
337412 func (p *preparedOutput) commit() {
338413 p.draw.Commit()
339414 p.stream.forceSnapshot = false
@@ -379,10 +454,10 @@ func (p *preparedOutput) currentLocked() bool {
379454 (p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration)
380455 }
381456
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) {
383458 output := protocol.Output{
384459 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,
386461 }
387462 if err := protocol.ValidateOutput(output); err != nil {
388463 return protocol.Output{}, err
@@ -408,7 +483,7 @@ func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (protoc
408483 if s != nil && s.attachment != nil {
409484 size = s.attachment.sizeSnapshot()
410485 }
411- return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
486+ return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false, nil)
412487 }
413488
414489 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) {
8585 stream := newOutputStateStream(1)
8686 frame := renderer.NewFrame(3, 1)
8787 fillOutputStateRows(frame, []string{"abc"})
88- prepared, err := stream.prepare(frame, nil, true)
88+ prepared, err := stream.prepareTestOutput(frame, nil, true)
8989 require.NoError(t, err)
9090
9191 entered := make(chan struct{})
@@ -98,7 +98,7 @@ func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
9898 return nil
9999 }))
100100 }()
101- <-entered
101+ awaitTestCompletion(t, entered, "output send was not admitted")
102102
103103 probed := make(chan struct{})
104104 go func() {
@@ -131,7 +131,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
131131
132132 first := newAttachment()
133133 second := newAttachment()
134- firstPrepared, err := first.prepare(frame, nil, true)
134+ firstPrepared, err := first.prepareTestOutput(frame, nil, true)
135135 require.NoError(t, err)
136136 var firstFrame wire.Frame
137137 require.NoError(t, firstPrepared.send(firstPrepared.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -145,7 +145,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
145145 require.True(t, firstOutput.Full)
146146 first.ack(firstOutput.Epoch, firstOutput.New)
147147
148- secondPrepared, err := second.prepare(frame, nil, true)
148+ secondPrepared, err := second.prepareTestOutput(frame, nil, true)
149149 require.NoError(t, err)
150150 require.NoError(t, secondPrepared.send(secondPrepared.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
151151 require.Equal(t, uint64(1), second.epoch)
@@ -154,7 +154,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
154154
155155 changed := frame.Clone()
156156 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)
158158 require.NoError(t, err)
159159 first.rebase()
160160 require.Equal(t, uint64(2), first.epoch)
@@ -170,7 +170,7 @@ func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *t
170170 first.ack(1, 1)
171171 require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")
172172
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)
174174 require.NoError(t, err)
175175 var resetFrame wire.Frame
176176 require.NoError(t, reset.send(reset.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -203,12 +203,12 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
203203 stream.attachment = ac
204204 frame := renderer.NewFrame(3, 1)
205205 fillOutputStateRows(frame, []string{"abc"})
206- initial, err := stream.prepare(frame, nil, true)
206+ initial, err := stream.prepareTestOutput(frame, nil, true)
207207 require.NoError(t, err)
208208 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
209209 changed := frame.Clone()
210210 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)
212212 require.NoError(t, err)
213213 tt.change(ac)
214214 var sent bool
@@ -219,7 +219,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
219219 require.False(t, sent)
220220 require.Equal(t, uint64(1), stream.next)
221221
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)
223223 require.NoError(t, err)
224224 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
225225 require.Equal(t, uint64(2), stream.next)
@@ -257,14 +257,14 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T)
257257 stream := newOutputStateStream()
258258 initial := renderer.NewFrame(3, 1)
259259 fillOutputStateRows(initial, []string{"abc"})
260- first, err := stream.prepare(initial, nil, true)
260+ first, err := stream.prepareTestOutput(initial, nil, true)
261261 require.NoError(t, err)
262262 require.NoError(t, first.send(first.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
263263 require.Equal(t, uint64(1), stream.next)
264264
265265 changed := initial.Clone()
266266 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)
268268 require.NoError(t, err)
269269 probe, err := stream.renderer.Prepare(initial, nil, false)
270270 require.NoError(t, err)
@@ -278,7 +278,7 @@ func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T)
278278 require.NoError(t, err)
279279 require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
280280
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)
282282 require.NoError(t, err)
283283 var sent wire.Frame
284284 require.NoError(t, retry.send(retry.data, 0, outputFrameSender(func(frame wire.Frame) error {
@@ -304,7 +304,7 @@ func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
304304 stream := newOutputStateStream()
305305 frame := renderer.NewFrame(3, 1)
306306 fillOutputStateRows(frame, []string{"abc"})
307- initial, err := stream.prepare(frame, nil, true)
307+ initial, err := stream.prepareTestOutput(frame, nil, true)
308308 require.NoError(t, err)
309309 require.NoError(t, initial.send(initial.data, 0, outputFrameSender(func(wire.Frame) error { return nil })))
310310
@@ -376,9 +376,11 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck
376376 if reset {
377377 base = 0
378378 }
379- output, err := serverOutputState(data, base, stream.next, echoAck)
380- if err != nil {
381- panic(err)
379+ context := publicationTestContext()
380+ context.Publication = stream.next
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,
382384 }
383385 frame, err := testServerFrame(output)
384386 if err != nil {
@@ -387,9 +389,20 @@ func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck
387389 return frame
388390 }
389391
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+
390403 func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (wire.Frame, bool, error) {
391404 t.Helper()
392- prepared, err := stream.prepare(frame, damage, reset)
405+ prepared, err := stream.prepareTestOutput(frame, damage, reset)
393406 if err != nil || len(prepared.data) == 0 {
394407 if prepared != nil {
395408 prepared.commitNoSend()
@@ -443,11 +456,15 @@ func TestAttachmentOutputFailedSendKeepsTextCursorAndGraphicsSpeculative(t *test
443456 require.NoError(t, err)
444457 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
445458 require.NoError(t, err)
446- state := &capturedRenderState{panes: []capturedPaneRenderState{{
447- graphics: scene.Snapshot(),
448- graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 1, Rows: 1}, PixelWidth: 1, PixelHeight: 1},
449- placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
450- }}}
459+ state := &capturedRenderState{
460+ route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}},
461+ view: attachmentView{tabID: "tab-1"},
462+ panes: []capturedPaneRenderState{{
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+ }}}
451468 graphicsState := newGraphicsOutputState()
452469 output := attachmentOutputWithGraphics(graphicsState)
453470 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) {
365365 require.True(t, source.geometry.reconcile(d, source, nil))
366366
367367 target := &session{
368- sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"},
368+ sessionCore: sessionCore{id: "target-geometry", name: "target-geometry", incarnation: newTestLifecycle(t)},
369369 ctx: source.ctx,
370370 cancel: func() {},
371371 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) {
295295 require.True(t, selectTestAttachmentTab(sess, 1))
296296 d.paint(sess, ac, true, nil)
297297 data := mustOutputData(t, sends)
298- // As above: the deferred all-clients repaint has nothing new to say about
299- // this client's bars, so no second frame follows.
298+ position, err := wire.UnmarshalRoutePosition(awaitFrame(t, sends, wire.MsgRoutePosition).Payload)
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.
300303 select {
301304 case f := <-sends:
302305 t.Fatalf("unexpected extra frame after ack: %v", f.Type)
@@ -460,7 +463,7 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
460463 tab2b.attention = true
461464 tab2b.attentionAt = time.Unix(5, 0)
462465 sess2 := &session{sessionCore: sessionCore{id: "other",
463- name: "other"}, ctx: sctx2,
466+ name: "other", incarnation: newTestLifecycle(t)}, ctx: sctx2,
464467 cancel: cancel2,
465468 tabs: []*tab{tab2a, tab2b},
466469 }
M internal/usecase/daemon/client_frame_routing.go +11 -0
@@ -104,7 +104,18 @@ func (d *Daemon) handleAttachmentClientMessage(capability attachmentCapability,
104104 }
105105 switch message := message.(type) {
106106 case protocol.Input:
107+ effect.uiActionID = message.ActionID
107108 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+ }
108119 case protocol.Resize:
109120 if effect.current() {
110121 d.resizeAttachmentGeometryForLease(effect, message.Geometry())
M internal/usecase/daemon/copy_scroll_ownership_test.go +4 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/protocol"
78 "github.com/bnema/vev/internal/protocol/wire"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 "github.com/stretchr/testify/require"
@@ -26,6 +27,8 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
2627 before := cloneComposeCache(ac.pipelineCache)
2728 state.bars.topRight = "retry"
2829 state.attachment = ac
30+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
31+ state.view.revision = ac.viewSnapshot().revision
2932 pending := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
3033 if failure == "prepare" {
3134 pending.frame = renderer.Frame{Width: 1}
@@ -44,6 +47,7 @@ func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
4447 ac.replaceTransport(healthy)
4548 }
4649 pending = composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
50+ state.view.revision = ac.viewSnapshot().revision
4751 ac.sendMu.Lock()
4852 require.True(t, d.emitFrame(sess, ac, &state, pending))
4953 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) {
1919 frame := renderer.NewFrame(182, 55)
2020 target := domain.Rect{Y: 1, Width: 182, Height: 53}
2121 styles := resolveStyles(nil)
22- result := testing.Benchmark(func(b *testing.B) {
23- for b.Loop() {
24- composeCopyClientFrame(mode, target, frame, styles)
25- }
26- })
2722 // A reusable semantic row and status text fit in 32 KiB. A second compact
2823 // 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)
3027 }
3128
3229 // 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) {
433433 hg.Go(func() { d.handleConn(tr) })
434434 awaitFrame(t, sends, wire.MsgWelcome)
435435 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")
437440
438441 for i := range 12 {
439442 reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
M internal/usecase/daemon/daemon_test.go +18 -1
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "crypto/rand"
56 "io"
67 "log/slog"
78 "sync"
@@ -402,6 +403,13 @@ func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ..
402403 return newManualSessionWithPTYsClockCleanup(t, stubClock{}, registerCleanup, ptys...)
403404 }
404405
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+
405413 func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan wire.Frame) {
406414 t.Helper()
407415 d := newTestDaemonWithCleanup(t, nil, clock, registerCleanup)
@@ -420,11 +428,20 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
420428 }
421429 tabs = append(tabs, tb)
422430 }
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}
424433 for _, tb := range tabs {
425434 publishTiledPaneOwners(sess, tb)
426435 }
427436 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+ }
428445 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil)
429446 d.sessions[sess.id] = sess
430447 if registerCleanup {
M internal/usecase/daemon/graphics_output_test.go +3 -1
@@ -284,7 +284,9 @@ func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) {
284284 prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
285285 require.NoError(t, err)
286286 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)
288290 require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")
289291 }
290292
M internal/usecase/daemon/input.go +1 -0
@@ -392,6 +392,7 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
392392 if !admitted {
393393 return nil, nil, false
394394 }
395+ effect.uiActionID = h.effect.uiActionID
395396 if h.d.afterAttachmentEffectAdmitted != nil {
396397 h.d.afterAttachmentEffectAdmitted(effect.capability())
397398 }
M internal/usecase/daemon/input_test.go +2 -2
@@ -834,8 +834,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
834834 current.id = "current"
835835 delete(d.sessions, domain.SessionID("manual"))
836836 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})}}
838- older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, 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", incarnation: newTestLifecycle(t)}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
839839 d.sessions[recent.id] = recent
840840 d.sessions[older.id] = older
841841 current.mruAt.Store(30)
M internal/usecase/daemon/move_test.go +5 -5
@@ -25,10 +25,10 @@ func TestMovePaneMutationSameSession(t *testing.T) {
2525 moved.ctx, moved.cancel = movedCtx, movedCancel
2626
2727 err := d.movePane(movePaneRequest{
28- Source: moveSessionLocator{ID: sess.id},
28+ Source: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
2929 SourceTabID: domain.TabStableID(sourceTab.stableID),
3030 SourcePaneID: domain.PaneStableID(moved.stableID),
31- Destination: moveSessionLocator{ID: sess.id},
31+ Destination: moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation},
3232 DestinationTabID: domain.TabStableID(destinationTab.stableID),
3333 })
3434 require.NoError(t, err)
@@ -65,7 +65,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
6565 d.mu.Unlock()
6666
6767 err := d.movePane(movePaneRequest{
68- Source: moveSessionLocator{ID: source.id},
68+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
6969 SourceTabID: domain.TabStableID(sourceTab.stableID),
7070 SourcePaneID: domain.PaneStableID(moved.stableID),
7171 Destination: moveSessionLocator{ID: destination.id},
@@ -105,7 +105,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
105105 d.mu.Unlock()
106106
107107 err = d.movePane(movePaneRequest{
108- Source: moveSessionLocator{ID: source.id},
108+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
109109 SourceTabID: domain.TabStableID(movedTab.stableID),
110110 SourcePaneID: domain.PaneStableID(moved.stableID),
111111 Destination: moveSessionLocator{ID: destination.id},
@@ -153,7 +153,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
153153 d.mu.Unlock()
154154
155155 require.NoError(t, d.movePane(movePaneRequest{
156- Source: moveSessionLocator{ID: source.id},
156+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
157157 SourceTabID: domain.TabStableID(movedTab.stableID),
158158 SourcePaneID: domain.PaneStableID(moved.stableID),
159159 Destination: moveSessionLocator{ID: destination.id},
M internal/usecase/daemon/navigation_overflow_test.go +18 -4
@@ -107,7 +107,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
107107 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}
108108 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
109109 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}
111111 }
112112 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
113113 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -278,12 +278,26 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
278278 }
279279
280280 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 }()
282296 alpha.mu.Lock()
283297 alpha.name = "alpha"
284298 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})}}
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})}}
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})}}
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})}}
287301 d.mu.Lock()
288302 d.sessions[echo.id] = echo
289303 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
288288 for _, pane := range tb.panes {
289289 pane.ctx, pane.cancel = wctx, wcancel
290290 }
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)}
292293 ac.setSession(sess)
293294 d.sessions[sess.id] = sess
294295 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) {
230230 }
231231
232232 // reapPaneOwner resolves the owner afresh after EOF. A stale attempt retries
233-// the immutable owner pointer; a successful attempt revokes owner publication
234-// under pane.mu before releasing membership locks, so a competing close or
235-// move can never reap the pane from a second owner.
233+// the immutable owner pointer. Non-final removal revokes it with membership;
234+// final-pane removal retains it until tab/session teardown commits, so stale
235+// attachment snapshots cannot abandon the EOF before closing its resources.
236236 func (d *Daemon) reapPaneOwner(p *pane) {
237237 for p != nil {
238238 lease := p.effectLease()
@@ -297,11 +297,17 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
297297 delete(tb.panes, p.id)
298298 tb.bumpLayoutGenerationLocked()
299299 }
300- p.clearOwnerLocked()
300+ if !finalPane {
301+ p.clearOwnerLocked()
302+ }
301303 p.mu.Unlock()
302304 tb.mu.Unlock()
303305 sess.invalidateViewsLocked()
304306 sess.mu.Unlock()
307+ attachmentTransports := make(map[*attachedClient]transportSnapshot, len(attachments))
308+ for _, ac := range attachments {
309+ attachmentTransports[ac] = ac.transportSnapshot()
310+ }
305311
306312 if finalPane {
307313 for _, ac := range attachments {
@@ -309,8 +315,36 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
309315 ac.overlays.clearCopyModeForPane(p)
310316 }
311317 }
312- _ = d.closeTabLocked(sess, tb, true)
313- return true
318+ if err := d.closeTabLocked(sess, tb, true); err != nil {
319+ 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
314348 }
315349 sess.geometry.applyTabLayout(d, sess, tb)
316350 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
910910 for _, p := range tb.panes {
911911 p.ctx, p.cancel = tctx, cancel
912912 }
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}
914914 return d, sess, oldPTY, factory
915915 }
916916
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) {
253253 require.NoError(t, err)
254254 require.True(t, output.Full)
255255 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)
256259 select {
257260 case frame := <-sends:
258261 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) {
328328 func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
329329 for _, topology := range daemonHistoryTopologies {
330330 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.
331334 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,
333336 })
334337 fixture.paintLive()
335338 metrics := fixture.metrics()
M internal/usecase/daemon/picker_test.go +8 -4
@@ -1104,8 +1104,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
11041104 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11051105 defer cancel1()
11061106 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)}}
1108- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
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", incarnation: newTestLifecycle(t), attachments: map[*attachedClient]struct{}{ac2: {}}}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
11091109 ac1.setSession(sess1)
11101110 ac2.setSession(sess2)
11111111 d.sessions[sess1.id] = sess1
@@ -1116,6 +1116,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
11161116 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
11171117 d.firstPaint(sess2, ac1)
11181118 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)
11191122 require.Same(t, sess2, ac1.currentSession())
11201123 for len(sends1) > 0 {
11211124 <-sends1
@@ -1159,8 +1162,9 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11591162 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11601163 defer cancel1()
11611164 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)}}
1163- sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
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)}}
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)}
11641168 ac.setSession(sess1)
11651169 d.sessions[sess1.id] = sess1
11661170 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
935935 ac.initOverlays()
936936 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
937937 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)}
939940 publishTiledPaneOwners(sess, tb)
940941 ac.setSession(sess)
941942 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
394394 ac.sendMu.Unlock()
395395 return paintBlockedCapacity
396396 }
397+ var uiFence uint64
398+ if lease != nil {
399+ if rc := attachmentRenderCoordinator(entry); rc != nil {
400+ uiFence = rc.captureUIFence(lease)
401+ }
402+ }
397403 // Composition owns attachment sendMu; initialize its lazy overlay state
398404 // under that same ownership so concurrent fallback paints cannot observe a
399405 // partially published runtime.
@@ -471,6 +477,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
471477 styleGeneration: applied.Generation,
472478 reset: reset,
473479 lease: lease,
480+ uiFence: uiFence,
474481 })
475482 endCapture(0, ok)
476483 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 @@
11 package daemon
22
33 import (
4+ "runtime"
45 "testing"
56
67 "github.com/stretchr/testify/require"
78 )
89
9-// Benchmark accounting measures allocated bytes, not retained heap. Setup and
10-// warm-up belong to the calling fixture, outside this measured operation.
10+// Measure bytes with the same bounded warmup/sampling strategy as
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.
1128 func assertRenderByteBudget(t *testing.T, run func(), maximum int64) {
1229 t.Helper()
13- result := testing.Benchmark(func(b *testing.B) {
14- for b.Loop() {
15- run()
16- }
17- })
18- require.LessOrEqual(t, result.AllocedBytesPerOp(), maximum, "rendering must preserve the compact byte-allocation benefit")
30+ require.LessOrEqual(t, renderAllocatedBytesPerRun(run), maximum, "rendering must preserve the compact byte-allocation benefit")
1931 }
M internal/usecase/daemon/render_capture.go +47 -8
@@ -4,6 +4,7 @@ import (
44 renderer "github.com/bnema/vev-vt"
55 vevgraphics "github.com/bnema/vev-vt/graphics"
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol"
78 scopy "github.com/bnema/vev/internal/usecase/copy"
89 "github.com/bnema/vev/internal/usecase/layout"
910 "github.com/bnema/vev/internal/usecase/picker"
@@ -44,9 +45,12 @@ type damageReceipt struct {
4445 type capturedRenderState struct {
4546 attachment *attachedClient // identity only; never dereferenced by composition
4647 sessionID domain.SessionID
48+ route protocol.CommittedRouteIdentity
4749 incarnation domain.IncarnationID
4850 lease *attachmentLease
51+ uiFence uint64
4952 view attachmentView
53+ focusedPaneID domain.PaneStableID
5054 window domain.Size
5155 reset bool
5256 layout capturedTabLayout
@@ -68,6 +72,22 @@ type capturedRenderState struct {
6872 receipts []damageReceipt // private capture receipts; compose must not inspect these
6973 }
7074
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+
7191 type capturedTabLayout struct {
7292 area domain.Rect
7393 focus layout.PaneID
@@ -255,6 +275,7 @@ type renderCaptureRequest struct {
255275 styleGeneration uint64
256276 reset bool
257277 lease *attachmentLease
278+ uiFence uint64
258279 }
259280
260281 // captureLocalRenderState is the ownership boundary for a local attachment
@@ -279,15 +300,22 @@ func captureLocalRenderState(
279300 _, owned := sess.attachments[ac]
280301 sessionID := sess.id
281302 incarnation := sess.incarnation
303+ route := protocol.CommittedRouteIdentity{
304+ Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
305+ Ephemeral: sess.ephemeral,
306+ }
282307 sess.mu.Unlock()
283308 if !owned {
284309 return nil, false
285310 }
286- tb, focusedPane := sess.paneForAttachment(ac)
311+ tb := sess.tabForAttachment(ac)
287312 if tb == nil {
288- tb = sess.tabForAttachment(ac)
313+ return nil, false
289314 }
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) {
291319 return nil, false
292320 }
293321
@@ -317,8 +345,18 @@ func captureLocalRenderState(
317345 defer tb.mu.Unlock()
318346 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
319347 var focus layout.PaneID
320- if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
321- focus = focusedPane.id
348+ var focusedPaneID domain.PaneStableID
349+ 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
322360 }
323361 layoutSnap := tabLayoutSnapshot{
324362 area: area, focus: focus, placements: scratch.placements,
@@ -344,15 +382,16 @@ func captureLocalRenderState(
344382 layoutSnap.dividers = scratch.dividers
345383 }
346384 state := &scratch.state
347- view := ac.viewSnapshot()
348385 window := domain.Size{}
349386 if view.windowSet {
350387 window = ac.sizeSnapshot()
351388 }
352389 *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,
354391 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,
356395 overlays: overlays, preview: preview,
357396 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
358397 floatingGeneration: tb.floating.generation,
M internal/usecase/daemon/render_coordinator.go +7 -0
@@ -165,6 +165,10 @@ type renderInvalidationReservation struct {
165165 func (c *renderCoordinator) reserveInvalidationForLeaseAtResizeEpoch(source *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) (*renderInvalidationReservation, bool) {
166166 c.mu.Lock()
167167 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) {
168172 if c.torndown || (lease != nil && (!c.leaseCurrentLocked(lease, true) || lease.attachment != source)) ||
169173 (source != nil && !c.attachmentRegisteredLocked(source)) ||
170174 (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
719723 return nil
720724 }
721725 if lease := c.leases[ac]; lease != nil {
726+ lease.cancelUIFenceLocked()
722727 lease.active = false
723728 delete(c.leases, ac)
724729 }
@@ -781,6 +786,7 @@ func (c *renderCoordinator) beginDetachLocked(ac *attachedClient) renderLifecycl
781786 }
782787 delete(c.attachments, ac)
783788 if lease := c.leases[ac]; lease != nil {
789+ lease.cancelUIFenceLocked()
784790 lease.active = false
785791 delete(c.leases, ac)
786792 }
@@ -841,6 +847,7 @@ func (c *renderCoordinator) beginSessionTeardown() renderLifecycleCleanup {
841847 }
842848 c.torndown = true
843849 for ac, lease := range c.leases {
850+ lease.cancelUIFenceLocked()
844851 lease.active = false
845852 delete(c.attachments, ac)
846853 }
M internal/usecase/daemon/render_coordinator_types.go +3 -0
@@ -75,6 +75,9 @@ type attachmentLease struct {
7575 // outlives the coordinator's coalesced delivered map so an older capacity
7676 // failure cannot overwrite a newer attempt.
7777 deliveredGeneration uint64
78+ // UI fences are exact-lease capabilities, never inferred from wake delivery.
79+ requestedUIFence uint64
80+ pendingUIFence *pendingUIFence
7881 // A lease is an immutable callback capability. Its lifecycle and delivery
7982 // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each
8083 // 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
477477 entry.core().mu.Lock()
478478 _, owned := entry.core().attachments[ac]
479479 position := protocol.RoutePosition{
480- Target: protocol.ExactSessionTarget{
481- LifecycleID: entry.core().incarnation,
482- SessionName: entry.core().name,
483- },
480+ Target: state.route.Target,
484481 ActiveTabID: state.view.tabID,
485482 }
486483 entry.core().mu.Unlock()
@@ -535,8 +532,10 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
535532 }
536533 var sendTr ports.ServerConnection
537534 var sendErr error
535+ rc := attachmentRenderCoordinator(entry)
536+ confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence)
538537 suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
539- if len(data) > 0 {
538+ if len(data) > 0 || prepared.ansi.context != nil {
540539 sendTransport := ac.transportSnapshot()
541540 sendTr = sendTransport.transport
542541 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -555,7 +554,16 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
555554 }
556555 }
557556 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+ }
559567 }
560568 if marks.attachmentEffect != nil && interruptible {
561569 if sendErr != nil {
@@ -630,6 +638,21 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
630638 }
631639 }
632640 }
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+ }
633656 }
634657 ac.sendMu.Unlock()
635658 if suppressedGraphics && d.warnUnsupportedGraphics(ac) {
M internal/usecase/daemon/render_pipeline_cache_test.go +14 -6
@@ -8,6 +8,7 @@ import (
88
99 renderer "github.com/bnema/vev-vt"
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
1112 "github.com/bnema/vev/internal/protocol/wire"
1213 "github.com/bnema/vev/internal/usecase/layout"
1314 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -49,6 +50,8 @@ func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
4950
5051 state := cacheState("next", 2)
5152 state.attachment = ac
53+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
54+ state.view.revision = ac.viewSnapshot().revision
5255 ac.sendMu.Lock()
5356 require.True(t, d.emitFrame(sess, ac, &state, pending))
5457 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) {
6568 pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
6669 state = cacheState("next", 2)
6770 state.attachment = ac
71+ state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
72+ state.view.revision = ac.viewSnapshot().revision
6873 ac.sendMu.Lock()
6974 require.True(t, d.emitFrame(sess, ac, &state, pending))
7075 require.Equal(t, pending.cache, ac.pipelineCache)
@@ -322,12 +327,15 @@ func cacheState(title string, generation uint64) capturedRenderState {
322327 floating := renderer.NewFrame(2, 1)
323328 floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
324329 return capturedRenderState{
325- reset: false,
326- layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
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()}}},
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},
329- bars: barState{topRight: title, bottomRight: title},
330- styles: resolveStyles(nil),
330+ route: publicationTestContext().Route,
331+ view: attachmentView{tabID: "tab-1"},
332+ focusedPaneID: "pane-1",
333+ reset: false,
334+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
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),
331339 }
332340 }
333341
M internal/usecase/daemon/render_pipeline_test.go +22 -1
@@ -14,6 +14,7 @@ import (
1414 vt "github.com/bnema/vev-vt"
1515 renderer "github.com/bnema/vev-vt/ansi"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/wire"
1819 "github.com/bnema/vev/internal/testutil/replaytest"
1920 scopy "github.com/bnema/vev/internal/usecase/copy"
@@ -263,6 +264,9 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
263264 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
264265 state := cacheState("steady", 1)
265266 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
266270 initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
267271 ac.sendMu.Lock()
268272 require.True(t, d.emitFrame(sess, ac, &state, initial))
@@ -298,6 +302,21 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
298302 t.Fatalf("no-byte transaction sent state frame %#v", frame)
299303 default:
300304 }
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)
301320 }
302321
303322 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
@@ -316,9 +335,11 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
316335 modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
317336 }
318337 state := capturedRenderState{
338+ route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sess"}},
339+ view: attachmentView{tabID: "tab-1"},
319340 reset: true,
320341 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()}}},
322343 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},
323344 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
324345 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) {
943943 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
944944 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
945945 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}}
947947 ac.setSession(sess)
948948
949949 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) {
276276 case protocol.Output:
277277 payload, err := wire.MarshalOutput(m)
278278 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
279285 case protocol.Detached:
280286 return wire.Frame{Type: wire.MsgDetached, Payload: wire.MarshalDetached(m)}, nil
281287 case protocol.Pong:
@@ -296,6 +302,9 @@ func testServerFrame(message protocol.ServerMessage) (wire.Frame, error) {
296302 case protocol.RouteNavigationAction:
297303 payload, err := wire.MarshalRouteNavigationAction(m)
298304 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
299308 case protocol.RouteNavigationFailure:
300309 payload, err := wire.MarshalRouteNavigationFailure(m)
301310 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 {
3636 return protocol.ErrorMsg{Code: code, Text: text}
3737 }
3838
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-
5039 func serverDetached(reason uint8) protocol.Detached { return protocol.Detached{Reason: reason} }
5140 func serverPong() protocol.Pong { return protocol.Pong{} }
5241 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 {
124124 pending []byte
125125 }
126126
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+
127141 // Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
128142 // responses terminated by BEL or ST.
129143 // 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 {
3030 func (*probeTestTransport) Recv() (wire.Frame, error) { return wire.Frame{}, errors.New("not used") }
3131 func (*probeTestTransport) Close() error { return nil }
3232
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+
3344 func probeTestOutput(t *testing.T, output protocol.Output) wire.Frame {
3445 t.Helper()
35- payload, err := wire.MarshalOutput(output)
46+ payload, err := wire.MarshalOutput(probeTestSemanticOutput(output))
3647 if err != nil {
3748 t.Fatalf("marshal output: %v", err)
3849 }
@@ -62,7 +73,7 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
6273 },
6374 } {
6475 t.Run(tt.name, func(t *testing.T) {
65- result := probe.apply(tt.output)
76+ result := probe.apply(probeTestSemanticOutput(tt.output))
6677 if !result.Accepted || !result.StateBearing {
6778 t.Fatalf("result = %+v, want accepted state-bearing output", result)
6879 }
@@ -88,9 +99,9 @@ func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
8899 func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
89100 probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
90101 outputSize := domain.Size{Cols: 48, Rows: 3}
91- result := probe.apply(protocol.Output{
102+ result := probe.apply(probeTestSemanticOutput(protocol.Output{
92103 Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
93- })
104+ }))
94105
95106 require.True(t, result.Accepted)
96107 require.Equal(t, outputSize.Cols, probe.screen.Columns())
@@ -122,11 +133,11 @@ func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
122133 t.Run(tt.name, func(t *testing.T) {
123134 probe := newVisualProbe(size)
124135 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 {
126137 t.Fatal("seed output was rejected")
127138 }
128139 beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
129- result := probe.apply(tt.output)
140+ result := probe.apply(probeTestSemanticOutput(tt.output))
130141 if result.Accepted || result.StateBearing || result.Ack != (protocol.Ack{}) {
131142 t.Fatalf("result = %+v, want rejected output without ACK", result)
132143 }
@@ -179,7 +190,7 @@ func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
179190 } {
180191 t.Run(tt.name, func(t *testing.T) {
181192 probe := newVisualProbe(size)
182- if tt.seed != nil && !probe.apply(*tt.seed).Accepted {
193+ if tt.seed != nil && !probe.apply(probeTestSemanticOutput(*tt.seed)).Accepted {
183194 t.Fatal("seed output was rejected")
184195 }
185196 beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
@@ -312,7 +323,7 @@ func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
312323 if state == 1 {
313324 output.Full = true
314325 }
315- if result := probe.apply(output); !result.Accepted {
326+ if result := probe.apply(probeTestSemanticOutput(output)); !result.Accepted {
316327 t.Fatalf("probe output %d was rejected", state)
317328 }
318329 }

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