detached 6e29c9b

test(phase5): add unified session acceptance coverage

commit
6e29c9baa2ef573fee2347362ad97d82dc086ffa
author
brice · · 2026-08-03 14:47 UTC
committer
brice · · 2026-08-04 13:34 UTC
parent
785b650
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +630 -224
M AGENTS.md +6 -4
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.
67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25` (`MsgAttachTarget`). Types `14–15` and `24` remain reserved.
6868 - Version negotiation requires strict equality.
6969 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
@@ -74,6 +74,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
7474 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
7575 - Named sessions survive headless and persist across daemon restarts.
7676 - The daemon starts on first use and exits when the last session ends.
77-- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
78-- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.
79-- Only one client may attach to a session; a new attach replaces the old client.
77+- Each connection has a 15-second handshake budget from connect through the initial committed publication.
78+- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79+- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80+- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.
81+- Command requests have a 10-second result deadline and are tracked per connection.
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25` (`MsgAttachTarget`). Types `14–15` and `24` remain reserved.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
@@ -74,6 +74,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
74 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.74 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
75 - Named sessions survive headless and persist across daemon restarts.75 - Named sessions survive headless and persist across daemon restarts.
76 - The daemon starts on first use and exits when the last session ends.76 - The daemon starts on first use and exits when the last session ends.
77-- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.77+- Each connection has a 15-second handshake budget from connect through the initial committed publication.
78-- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.78+- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79-- Only one client may attach to a session; a new attach replaces the old client.79+- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80+- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.
81+- Command requests have a 10-second result deadline and are tracked per connection.
M README.md +4 -4
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
5151
5252 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
5353
54-Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.
54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.
5555
5656 ## Keys
5757
@@ -73,13 +73,13 @@ The palette does the rest: type a short code (`SPR` split right, `CNT` new tab,
7373 vev attach user@host[:session]
7474 ```
7575
76-SSH bootstraps the connection, then the session runs over UDP and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
76+The client opens a direct connection to the selected remote daemon. UDP is the default: SSH bootstraps an authenticated UDP endpoint, then the session runs over that connection and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
7777
7878 ```sh
7979 sudo ufw allow 61000:61023/udp
8080 ```
8181
82-Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
82+Where UDP is not an option, set `VEV_REMOTE_TRANSPORT=stdio` to keep the direct connection inside SSH; this is an explicit transport choice. Every connection has a 15-second handshake deadline, and each command request has a 10-second deadline. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
8383
8484 List sessions on a known host (OpenSSH resolves aliases from your SSH config):
8585
@@ -119,7 +119,7 @@ vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destinat
119119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>
120120 ```
121121
122-Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client.
122+Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its attachment follows the moved pane or tab and activates the destination; existing destination attachments keep their own views.
123123
124124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
125125
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
51 51
52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
53 53
54-Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.
55 55
56 ## Keys56 ## Keys
57 57
@@ -73,13 +73,13 @@ The palette does the rest: type a short code (`SPR` split right, `CNT` new tab,
73 vev attach user@host[:session]73 vev attach user@host[:session]
74 ```74 ```
75 75
76-SSH bootstraps the connection, then the session runs over UDP and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):76+The client opens a direct connection to the selected remote daemon. UDP is the default: SSH bootstraps an authenticated UDP endpoint, then the session runs over that connection and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
77 77
78 ```sh78 ```sh
79 sudo ufw allow 61000:61023/udp79 sudo ufw allow 61000:61023/udp
80 ```80 ```
81 81
82-Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).82+Where UDP is not an option, set `VEV_REMOTE_TRANSPORT=stdio` to keep the direct connection inside SSH; this is an explicit transport choice. Every connection has a 15-second handshake deadline, and each command request has a 10-second deadline. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
83 83
84 List sessions on a known host (OpenSSH resolves aliases from your SSH config):84 List sessions on a known host (OpenSSH resolves aliases from your SSH config):
85 85
@@ -119,7 +119,7 @@ vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destinat
119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>
120 ```120 ```
121 121
122-Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client.122+Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its attachment follows the moved pane or tab and activates the destination; existing destination attachments keep their own views.
123 123
124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
125 125
M docs/performance.md +69 -185
@@ -1,212 +1,96 @@
11 # Performance measurement
22
3-## S5a transport observability
4-
5-S5a is measurement-only. It records process-local runtime marks and harness-owned
6-end-to-end boundaries without changing rendering, pacing, transport policy, wire
7-bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/
8-transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
9-`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;
10-idle, active/all/inactive output, interactive flood, copy search, resize sweep,
11-snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP
12-baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),
13-with inapplicable cases required to be explicit. The seven transport entries are
14-`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,
15-and `udp_loss_1pct`.
16-
17-### Trace schema and clock domains
3+vev measures the current session and attachment stack with process-local runtime
4+marks and harness-owned end-to-end boundaries. Measurement does not alter
5+rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`.
6+
7+## Canonical matrix
8+
9+The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
10+
11+- topologies `1x4`, `4x1`, `4x4`, and `8x1`;
12+- `120x40` terminal content with 10,000 history rows per pane;
13+- idle, active output, all/inactive output, interactive flood, copy search,
14+ resize sweep, snapshot/output/resize, and attach/restore/tab switch;
15+- local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1%
16+ loss.
17+
18+The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19+transports). Inapplicable scenarios stay present with an explicit reason.
20+Multiple-attachment scenarios measure shared session mutations together with
21+independent attachment views and output streams.
22+
23+A connection handshake is bounded by 15 seconds. A command request is bounded
24+by 10 seconds. These limits apply during measurement as well as normal use.
25+
26+## Trace schema and clocks
1827
1928 Every process JSONL record has exactly these fields:
20-`schema` (uint), `process_id` (string), `component` (string), `scenario`
21-(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),
22-`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),
23-`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:
29+`schema`, `process_id`, `component`, `scenario`, `run`, `sequence`, `request_id`,
30+`epoch`, `kind`, `tick`, `bytes`, `fragments`, `retransmits`, `pending`,
31+`ack_rtt_nanos`, and `valid`.
32+
33+Schema 1 is represented by:
2434
2535 ```json
2636 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
2737 ```
2838
29-`tick` is stamped only by the concrete JSONL observer using its process-local
30-injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,
31-`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
32-launched role, identifying its exclusive trace path and process/scenario/run
33-mapping. Components in one OS
34-process share that observer. A spawned daemon is deliberately stripped of
35-these variables so it cannot append to a role's trace. The harness clock is a
36-separate monotonic domain and is the sole clock for input-injection → terminal-
37-bytes-successfully-flushed end-to-end samples. Cross-process ticks are never
38-ordered or subtracted; records correlate only by scenario/run/sequence/request/
39-epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false`
40-record explicitly reports bounded-observer loss; spans crossing that gap are
41-excluded, while an unmatched end without such a gap remains an error.
42-
43-The named process-local spans are capture, compose, diff, queue wait,
44-ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
45-marks are paired within one process and summarized by component/adapter with
46-raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
47-retransmits, pending, ACK RTT) remain separate from duration calculations.
48-
49-### Historical proxy ANSI baseline
50-
51-Before this refactor, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. This parser benchmark remains
53-a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
54-guardrail.
55-
56-The binaries were built from clean, exact commits in temporary clones so their
57-provenance is embedded and checkable:
39+The concrete JSONL observer stamps `tick` with its process-local injected
40+monotonic clock; producers supply no timestamp. Components in one OS process
41+share that observer. The harness owns a separate monotonic clock for the
42+input-injection to successfully-flushed-terminal-bytes boundary. Ticks from
43+different processes or clock domains are never ordered or subtracted. Records
44+correlate through scenario, run, sequence, request, and epoch IDs.
5845
59-```sh
60-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone
61-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c
62-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone
63-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df0
64-(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
65-(cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .)
66-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone
67-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d
68-(cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness)
69-go version -m /tmp/vev-proxy-baseline-1f2c802
70-go version -m /tmp/vev-proxy-candidate-2b48abf9
71-go version -m /tmp/vev-perf-harness-d479fe9
72-```
46+Process-local spans cover capture, compose, diff, queue wait, acknowledgement
47+blocking, emit, adapter send, and adapter receive. Bytes, fragments,
48+retransmits, pending work, and acknowledgement RTT remain diagnostics rather
49+than duration inputs. Reports retain raw records, counts, p50/p95/p99, and max
50+where applicable.
7351
74-The baseline benchmark below is a measured median of ten repetitions on
75-Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
76-`1f2c802d0707785cab979134db6a95645250a82c`:
52+## Running the matrix
7753
78-```sh
79-cd /tmp/vev-perf-baseline-clone
80-go test ./internal/usecase/daemon -run '^$' \
81- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
82- | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt
83-```
84-
85-| ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
86-| --- | ---: | ---: | ---: | ---: |
87-| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
88-| 120-column full line | 4,777 | 130 | 1 | 126 |
89-| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
90-| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
91-
92-Keep the ordinary local ANSI renderer as a separate guardrail:
54+Use a clean build and at least a 10-second warmup, a 30-second measured
55+interval, and ten independent repetitions:
9356
9457 ```sh
95-cd /tmp/vev-perf-candidate-2b48abf9-clone
96-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \
97- | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt
58+go build -o /tmp/vev ./
59+rm -rf testdata/perf/results
60+go run ./cmd/vev-perf-harness \
61+ --vev-bin /tmp/vev \
62+ --manifest testdata/perf/manifest.json \
63+ --out testdata/perf/results \
64+ --warmup 10s --duration 30s --repetitions 10
9865 ```
9966
100-The candidate guardrail completed with Go 1.26.5; its ten-repetition output is
101-retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.
67+Each run writes `manifest.json`, `raw-harness.jsonl`, one trace per launched
68+process, `runs.json`, and `summary.json`. The process manifest records process
69+identity, role, scenario/run, clock domain, and its exclusive trace path.
10270
103-### Public-CLI transport runs
71+The harness injects at most one measured event per second. A sample requires
72+both input injection and successful terminal flush inside the measured
73+interval; warmup, straddling, and failed events remain raw diagnostics. Reject
74+results with missing or unmatched boundaries, invalid correlation,
75+nonmonotonic same-process sequence, negative same-domain duration, fewer than
76+10 complete in-interval pairs, invalid denominators, cross-process tick math,
77+less than 30 seconds measured, or fewer than 10 repetitions.
10478
105-The adoption gate requires a 10-second warmup, a 30-second measured interval,
106-and ten independent repetitions for each of seven workloads × five transports
107-(35 scenarios), for both exact baseline and candidate binaries. This exact
108-command template was executed for every scenario (the two 35-scenario sets
109-were run separately to avoid oversubscribing the host):
79+Before a full run, validate the manifest and harness:
11080
11181 ```sh
112-for bin in baseline candidate; do
113- case "$bin" in
114- baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
115- candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
116- esac
117- for workload in active_output all_output inactive_output interactive_flood resize_sweep \
118- snapshot_output_resize attach_restore_tab_switch; do
119- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
120- scenario="1x4-${workload}-${transport}"
121- /tmp/vev-perf-harness-d479fe9 \
122- --vev-bin "$vev_bin" \
123- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
124- --scenario "$scenario" \
125- --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
126- --warmup 10s --duration 30s --repetitions 10
127- done
128- done
129-done
82+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
83+go test ./cmd/vev-perf-harness
13084 ```
13185
132-The measured outputs are retained only under `/tmp/vev-perf-results/`. Each
133-binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a
134-summary. The common completed results below are harness end-to-end medians
135-from `summary.json` (p50/p95, ns):
136-
137-| scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 |
138-| --- | ---: | ---: | ---: | ---: |
139-| 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 |
140-| 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 |
141-| 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 |
142-| 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 |
143-| 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 |
144-| 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 |
145-| 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 |
146-| 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 |
147-| 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 |
148-| 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 |
149-| 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 |
150-| 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 |
151-| 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 |
152-
153-`1x4-active_output-ssh_stdio` now completes for both binaries, so the former
154-trace-correlation blocker is fixed. The remaining failed scenarios are
155-retained as stderr evidence: all five `interactive_flood`, all five
156-`resize_sweep`, all five `snapshot_output_resize`, the four non-local
157-`attach_restore_tab_switch` transports, and the three `udp_loss_1pct`
158-output scenarios. They report terminal-start/readiness failures or missing
159-receive span pairs; they are not converted into samples.
160-
161-The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid
162-A/B summaries, so the 5% gate and adoption cannot be declared. The completed
163-common p50 values happened to stay within ±1.12%, but that is not a gate
164-result and does not substitute for the 22 missing scenarios.
165-
166-`summary.json` provenance was checked for every completed result:
167-`git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with
168-`vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and
169-`vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The
170-complete 252-scenario canonical matrix was not run because the representative
171-gate was incomplete. Its exact command remains:
86+For a bounded local check, add `--scenario 1x4-idle-local`. The harness still
87+validates the complete manifest before selecting that scenario. Do not commit
88+result directories; raw traces and run manifests are the measurement evidence.
17289
173-```sh
174-rm -rf /tmp/vev-perf-results/public-full-baseline
175-/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
176- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
177- --out /tmp/vev-perf-results/public-full-baseline \
178- --warmup 10s --duration 30s --repetitions 10
179-```
90+## In-process checks
18091
181-Warmup is excluded. During the measured interval the harness injects at most
182-one event per second. At least 10 complete event pairs are required: both input
183-injection and successful terminal flush must fall inside the interval.
184-Straddling or failed events remain raw diagnostics, not samples. Each run must
185-contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and
186-`summary.json`.
187-
188-`summary.json` records the harness source as `git_sha`, the measured vev binary
189-source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
190-manifests, and JSONL identify scenario, run, process, component, and correlation
191-IDs. Preserve those files with the benchmark output and record
192-`go version -m /tmp/vev-proxy-baseline-1f2c802`,
193-`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
194-`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
195-CPU and allocation evidence comes from Go microbenchmarks and process-local
196-span durations. End-to-end latency, terminal flush, and byte evidence comes
197-from the public-CLI harness. Reset, snapshot, and span counters for structured
198-output are candidate-only until that output exists.
199-
200-Reject results with missing input/flush boundaries, unmatched IDs, bad
201-correlation, nonmonotonic same-process sequence, negative same-domain spans,
202-fewer than 10 complete in-interval event pairs, invalid denominators, any
203-cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL
204-and run manifests are evidence; no result alone authorizes an optimization.
205-
206-## Existing in-process smoke checks
207-
208-These commands measure local daemon work only and are not end-to-end transport
209-results:
92+These commands measure local daemon or VT work only, not end-to-end transport
93+latency:
21094
21195 ```sh
21296 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
@@ -1,212 +1,96 @@
1 # Performance measurement1 # Performance measurement
2 2
3-## S5a transport observability3+vev measures the current session and attachment stack with process-local runtime
4-4+marks and harness-owned end-to-end boundaries. Measurement does not alter
5-S5a is measurement-only. It records process-local runtime marks and harness-owned5+rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`.
6-end-to-end boundaries without changing rendering, pacing, transport policy, wire6+
7-bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/7+## Canonical matrix
8-transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):8+
9-`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;9+The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
10-idle, active/all/inactive output, interactive flood, copy search, resize sweep,10+
11-snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP11+- topologies `1x4`, `4x1`, `4x4`, and `8x1`;
12-baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),12+- `120x40` terminal content with 10,000 history rows per pane;
13-with inapplicable cases required to be explicit. The seven transport entries are13+- idle, active output, all/inactive output, interactive flood, copy search,
14-`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,14+ resize sweep, snapshot/output/resize, and attach/restore/tab switch;
15-and `udp_loss_1pct`.15+- local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1%
16-16+ loss.
17-### Trace schema and clock domains17+
18+The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19+transports). Inapplicable scenarios stay present with an explicit reason.
20+Multiple-attachment scenarios measure shared session mutations together with
21+independent attachment views and output streams.
22+
23+A connection handshake is bounded by 15 seconds. A command request is bounded
24+by 10 seconds. These limits apply during measurement as well as normal use.
25+
26+## Trace schema and clocks
18 27
19 Every process JSONL record has exactly these fields:28 Every process JSONL record has exactly these fields:
20-`schema` (uint), `process_id` (string), `component` (string), `scenario`29+`schema`, `process_id`, `component`, `scenario`, `run`, `sequence`, `request_id`,
21-(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),30+`epoch`, `kind`, `tick`, `bytes`, `fragments`, `retransmits`, `pending`,
22-`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),31+`ack_rtt_nanos`, and `valid`.
23-`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:32+
33+Schema 1 is represented by:
24 34
25 ```json35 ```json
26 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}36 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
27 ```37 ```
28 38
29-`tick` is stamped only by the concrete JSONL observer using its process-local39+The concrete JSONL observer stamps `tick` with its process-local injected
30-injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,40+monotonic clock; producers supply no timestamp. Components in one OS process
31-`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every41+share that observer. The harness owns a separate monotonic clock for the
32-launched role, identifying its exclusive trace path and process/scenario/run42+input-injection to successfully-flushed-terminal-bytes boundary. Ticks from
33-mapping. Components in one OS43+different processes or clock domains are never ordered or subtracted. Records
34-process share that observer. A spawned daemon is deliberately stripped of44+correlate through scenario, run, sequence, request, and epoch IDs.
35-these variables so it cannot append to a role's trace. The harness clock is a
36-separate monotonic domain and is the sole clock for input-injection → terminal-
37-bytes-successfully-flushed end-to-end samples. Cross-process ticks are never
38-ordered or subtracted; records correlate only by scenario/run/sequence/request/
39-epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false`
40-record explicitly reports bounded-observer loss; spans crossing that gap are
41-excluded, while an unmatched end without such a gap remains an error.
42-
43-The named process-local spans are capture, compose, diff, queue wait,
44-ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
45-marks are paired within one process and summarized by component/adapter with
46-raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
47-retransmits, pending, ACK RTT) remain separate from duration calculations.
48-
49-### Historical proxy ANSI baseline
50-
51-Before this refactor, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. This parser benchmark remains
53-a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
54-guardrail.
55-
56-The binaries were built from clean, exact commits in temporary clones so their
57-provenance is embedded and checkable:
58 45
59-```sh46+Process-local spans cover capture, compose, diff, queue wait, acknowledgement
60-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone47+blocking, emit, adapter send, and adapter receive. Bytes, fragments,
61-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c48+retransmits, pending work, and acknowledgement RTT remain diagnostics rather
62-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone49+than duration inputs. Reports retain raw records, counts, p50/p95/p99, and max
63-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df050+where applicable.
64-(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
65-(cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .)
66-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone
67-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d
68-(cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness)
69-go version -m /tmp/vev-proxy-baseline-1f2c802
70-go version -m /tmp/vev-proxy-candidate-2b48abf9
71-go version -m /tmp/vev-perf-harness-d479fe9
72-```
73 51
74-The baseline benchmark below is a measured median of ten repetitions on52+## Running the matrix
75-Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
76-`1f2c802d0707785cab979134db6a95645250a82c`:
77 53
78-```sh54+Use a clean build and at least a 10-second warmup, a 30-second measured
79-cd /tmp/vev-perf-baseline-clone55+interval, and ten independent repetitions:
80-go test ./internal/usecase/daemon -run '^$' \
81- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
82- | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt
83-```
84-
85-| ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
86-| --- | ---: | ---: | ---: | ---: |
87-| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
88-| 120-column full line | 4,777 | 130 | 1 | 126 |
89-| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
90-| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
91-
92-Keep the ordinary local ANSI renderer as a separate guardrail:
93 56
94 ```sh57 ```sh
95-cd /tmp/vev-perf-candidate-2b48abf9-clone58+go build -o /tmp/vev ./
96-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \59+rm -rf testdata/perf/results
97- | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt60+go run ./cmd/vev-perf-harness \
61+ --vev-bin /tmp/vev \
62+ --manifest testdata/perf/manifest.json \
63+ --out testdata/perf/results \
64+ --warmup 10s --duration 30s --repetitions 10
98 ```65 ```
99 66
100-The candidate guardrail completed with Go 1.26.5; its ten-repetition output is67+Each run writes `manifest.json`, `raw-harness.jsonl`, one trace per launched
101-retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.68+process, `runs.json`, and `summary.json`. The process manifest records process
69+identity, role, scenario/run, clock domain, and its exclusive trace path.
102 70
103-### Public-CLI transport runs71+The harness injects at most one measured event per second. A sample requires
72+both input injection and successful terminal flush inside the measured
73+interval; warmup, straddling, and failed events remain raw diagnostics. Reject
74+results with missing or unmatched boundaries, invalid correlation,
75+nonmonotonic same-process sequence, negative same-domain duration, fewer than
76+10 complete in-interval pairs, invalid denominators, cross-process tick math,
77+less than 30 seconds measured, or fewer than 10 repetitions.
104 78
105-The adoption gate requires a 10-second warmup, a 30-second measured interval,79+Before a full run, validate the manifest and harness:
106-and ten independent repetitions for each of seven workloads × five transports
107-(35 scenarios), for both exact baseline and candidate binaries. This exact
108-command template was executed for every scenario (the two 35-scenario sets
109-were run separately to avoid oversubscribing the host):
110 80
111 ```sh81 ```sh
112-for bin in baseline candidate; do82+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
113- case "$bin" in83+go test ./cmd/vev-perf-harness
114- baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
115- candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
116- esac
117- for workload in active_output all_output inactive_output interactive_flood resize_sweep \
118- snapshot_output_resize attach_restore_tab_switch; do
119- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
120- scenario="1x4-${workload}-${transport}"
121- /tmp/vev-perf-harness-d479fe9 \
122- --vev-bin "$vev_bin" \
123- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
124- --scenario "$scenario" \
125- --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
126- --warmup 10s --duration 30s --repetitions 10
127- done
128- done
129-done
130 ```84 ```
131 85
132-The measured outputs are retained only under `/tmp/vev-perf-results/`. Each86+For a bounded local check, add `--scenario 1x4-idle-local`. The harness still
133-binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a87+validates the complete manifest before selecting that scenario. Do not commit
134-summary. The common completed results below are harness end-to-end medians88+result directories; raw traces and run manifests are the measurement evidence.
135-from `summary.json` (p50/p95, ns):
136-
137-| scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 |
138-| --- | ---: | ---: | ---: | ---: |
139-| 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 |
140-| 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 |
141-| 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 |
142-| 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 |
143-| 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 |
144-| 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 |
145-| 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 |
146-| 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 |
147-| 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 |
148-| 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 |
149-| 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 |
150-| 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 |
151-| 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 |
152-
153-`1x4-active_output-ssh_stdio` now completes for both binaries, so the former
154-trace-correlation blocker is fixed. The remaining failed scenarios are
155-retained as stderr evidence: all five `interactive_flood`, all five
156-`resize_sweep`, all five `snapshot_output_resize`, the four non-local
157-`attach_restore_tab_switch` transports, and the three `udp_loss_1pct`
158-output scenarios. They report terminal-start/readiness failures or missing
159-receive span pairs; they are not converted into samples.
160-
161-The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid
162-A/B summaries, so the 5% gate and adoption cannot be declared. The completed
163-common p50 values happened to stay within ±1.12%, but that is not a gate
164-result and does not substitute for the 22 missing scenarios.
165-
166-`summary.json` provenance was checked for every completed result:
167-`git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with
168-`vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and
169-`vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The
170-complete 252-scenario canonical matrix was not run because the representative
171-gate was incomplete. Its exact command remains:
172 89
173-```sh90+## In-process checks
174-rm -rf /tmp/vev-perf-results/public-full-baseline
175-/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
176- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
177- --out /tmp/vev-perf-results/public-full-baseline \
178- --warmup 10s --duration 30s --repetitions 10
179-```
180 91
181-Warmup is excluded. During the measured interval the harness injects at most92+These commands measure local daemon or VT work only, not end-to-end transport
182-one event per second. At least 10 complete event pairs are required: both input93+latency:
183-injection and successful terminal flush must fall inside the interval.
184-Straddling or failed events remain raw diagnostics, not samples. Each run must
185-contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and
186-`summary.json`.
187-
188-`summary.json` records the harness source as `git_sha`, the measured vev binary
189-source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
190-manifests, and JSONL identify scenario, run, process, component, and correlation
191-IDs. Preserve those files with the benchmark output and record
192-`go version -m /tmp/vev-proxy-baseline-1f2c802`,
193-`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
194-`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
195-CPU and allocation evidence comes from Go microbenchmarks and process-local
196-span durations. End-to-end latency, terminal flush, and byte evidence comes
197-from the public-CLI harness. Reset, snapshot, and span counters for structured
198-output are candidate-only until that output exists.
199-
200-Reject results with missing input/flush boundaries, unmatched IDs, bad
201-correlation, nonmonotonic same-process sequence, negative same-domain spans,
202-fewer than 10 complete in-interval event pairs, invalid denominators, any
203-cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL
204-and run manifests are evidence; no result alone authorizes an optimization.
205-
206-## Existing in-process smoke checks
207-
208-These commands measure local daemon work only and are not end-to-end transport
209-results:
210 94
211 ```sh95 ```sh
212 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem96 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
M docs/remote-resilience.md +53 -27
@@ -1,45 +1,71 @@
11 # Remote resilience
22
3-Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
3+Remote attachments connect directly to the selected vev daemon. UDP is the
4+normal carriage: SSH bootstraps an authenticated endpoint, then the attachment
5+carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
6+SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7+the remote host.
48
5-The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
9+The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10+panes, a fixed PTY content size, and ordered mutations. Each attachment keeps
11+its own window size, selected tab and pane, copy mode, overlays, render/output
12+acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13+attachment without replacing or changing its peers.
614
7-## Operational model
15+## Connection limits
816
9-- SSH bootstrap starts or finds the remote UDP endpoint.
10-- UDP carries normal session traffic after bootstrap. The remote proxy binds a port in a fixed range, `61000-61023` by default; `VEV_UDP_PORT_RANGE` overrides it (a range, a single port, or `0` for a random ephemeral port).
11-- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.
12-- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.
17+- A connection handshake has one 15-second deadline from connection start
18+ through Hello, Welcome, and the initial committed publication. The deadline
19+ applies to local and remote connections.
20+- Each command request has a 10-second deadline for its result. A timeout
21+ abandons only that request; it does not complete a later request with a stale
22+ result.
23+- The first valid attachment establishes the session's PTY content size. A
24+ later attachment can resize its own window without resizing shared PTYs.
1325
14-## Recovery states
26+## UDP operation
1527
16-Resilience work uses these user-facing states:
28+- The remote host selects a UDP port in `61000-61023` by default. Set
29+ `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
30+- The UDP carriage authenticates and retransmits protocol frames, preserves
31+ frame order, and reports bounded link-health state without logging payloads,
32+ addresses, keys, or identities.
33+- The attachment reconnects with its resume token after a path change. The
34+ remote session and its PTYs remain in place while the attachment is offline.
1735
18-| State | Meaning |
19-| --- | --- |
20-| `connected` | a complete record or meaningful ACK progress is recent |
21-| `degraded` | authenticated UDP packets still arrive, but complete-record or ACK progress is delayed |
22-| `probing` | authenticated UDP packet contact is absent, so vev probes for path recovery |
23-| `resuming` | a replacement transport is attaching to the same session |
24-| `offline` | authenticated UDP contact remains absent; vev re-runs the SSH bootstrap and re-dials UDP with the resume token |
25-| `expired` | the session or recovery window is no longer available |
36+The raw UDP AEAD key is held only in memory during bootstrap and transport
37+setup. It is not written to durable state. Key buffers are cleared on the
38+reachable handoff paths on a best-effort basis; cipher implementations and
39+other I/O owners may retain internal copies for their lifetime.
2640
27-When a terminal is in raw mode, recovery messages appear as vev status text rather than as bytes written into the remote program's output stream.
41+## Connection states
2842
29-`VEV_REMOTE_TRANSPORT=stdio` is the SSH-stdio fallback: it keeps the session inside authenticated SSH when UDP is blocked or unavailable. It remains available while UDP recovery is attempted.
43+| State | Meaning |
44+| --- | --- |
45+| `connected` | Recent authenticated contact and complete frame progress. |
46+| `degraded` | Authenticated packets arrive, but complete-frame or acknowledgement progress is delayed. |
47+| `probing` | Authenticated contact is absent and the carriage is probing for a recovered path. |
48+| `resuming` | This attachment is opening a replacement connection with its resume token. |
49+| `offline` | The attachment is re-establishing the selected remote transport. |
50+| `expired` | The session or attachment resume window is no longer available. |
3051
31-## UDP transport health diagnostics
52+In raw terminal mode, state changes appear as vev status text rather than bytes
53+written into the PTY's output stream.
3254
33-At debug logging, UDP transport-health lines report only state, elapsed progress ages, and bounded transport counters; they do not include payloads, addresses, keys, or identities. For a real remote attempt, `scripts/debug-remote-attach.sh --udp-health user@host` stores redacted transport-health lines and Linux `ss -u -m` socket-memory counters, then prints the exact collected file paths.
55+## Diagnostics and checks
3456
35-## Manual checks
57+At debug level, transport-health logs contain only state, elapsed progress ages,
58+and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
59+collects redacted health lines and Linux `ss -u -m` socket-memory counters.
3660
37-Useful scenarios for validating resilience behavior:
61+Useful checks are:
3862
3963 - block and unblock the configured UDP port range;
4064 - move between networks or change VPN state;
41-- suspend and resume the client machine;
42-- retry `vev attach user@host:session` for the same named session;
43-- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.
65+- suspend and resume the attachment machine;
66+- reconnect to the same named session;
67+- verify that a second attachment retains its independent view while the first
68+ reconnects.
4469
45-Keep hostnames, usernames, and local paths out of shared logs and screenshots.
70+Keep hostnames, usernames, keys, and local paths out of shared logs and
71+screenshots.
@@ -1,45 +1,71 @@
1 # Remote resilience1 # Remote resilience
2 2
3-Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.3+Remote attachments connect directly to the selected vev daemon. UDP is the
4+normal carriage: SSH bootstraps an authenticated endpoint, then the attachment
5+carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
6+SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7+the remote host.
4 8
5-The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.9+The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10+panes, a fixed PTY content size, and ordered mutations. Each attachment keeps
11+its own window size, selected tab and pane, copy mode, overlays, render/output
12+acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13+attachment without replacing or changing its peers.
6 14
7-## Operational model15+## Connection limits
8 16
9-- SSH bootstrap starts or finds the remote UDP endpoint.17+- A connection handshake has one 15-second deadline from connection start
10-- UDP carries normal session traffic after bootstrap. The remote proxy binds a port in a fixed range, `61000-61023` by default; `VEV_UDP_PORT_RANGE` overrides it (a range, a single port, or `0` for a random ephemeral port).18+ through Hello, Welcome, and the initial committed publication. The deadline
11-- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.19+ applies to local and remote connections.
12-- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.20+- Each command request has a 10-second deadline for its result. A timeout
21+ abandons only that request; it does not complete a later request with a stale
22+ result.
23+- The first valid attachment establishes the session's PTY content size. A
24+ later attachment can resize its own window without resizing shared PTYs.
13 25
14-## Recovery states26+## UDP operation
15 27
16-Resilience work uses these user-facing states:28+- The remote host selects a UDP port in `61000-61023` by default. Set
29+ `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
30+- The UDP carriage authenticates and retransmits protocol frames, preserves
31+ frame order, and reports bounded link-health state without logging payloads,
32+ addresses, keys, or identities.
33+- The attachment reconnects with its resume token after a path change. The
34+ remote session and its PTYs remain in place while the attachment is offline.
17 35
18-| State | Meaning |36+The raw UDP AEAD key is held only in memory during bootstrap and transport
19-| --- | --- |37+setup. It is not written to durable state. Key buffers are cleared on the
20-| `connected` | a complete record or meaningful ACK progress is recent |38+reachable handoff paths on a best-effort basis; cipher implementations and
21-| `degraded` | authenticated UDP packets still arrive, but complete-record or ACK progress is delayed |39+other I/O owners may retain internal copies for their lifetime.
22-| `probing` | authenticated UDP packet contact is absent, so vev probes for path recovery |
23-| `resuming` | a replacement transport is attaching to the same session |
24-| `offline` | authenticated UDP contact remains absent; vev re-runs the SSH bootstrap and re-dials UDP with the resume token |
25-| `expired` | the session or recovery window is no longer available |
26 40
27-When a terminal is in raw mode, recovery messages appear as vev status text rather than as bytes written into the remote program's output stream.41+## Connection states
28 42
29-`VEV_REMOTE_TRANSPORT=stdio` is the SSH-stdio fallback: it keeps the session inside authenticated SSH when UDP is blocked or unavailable. It remains available while UDP recovery is attempted.43+| State | Meaning |
44+| --- | --- |
45+| `connected` | Recent authenticated contact and complete frame progress. |
46+| `degraded` | Authenticated packets arrive, but complete-frame or acknowledgement progress is delayed. |
47+| `probing` | Authenticated contact is absent and the carriage is probing for a recovered path. |
48+| `resuming` | This attachment is opening a replacement connection with its resume token. |
49+| `offline` | The attachment is re-establishing the selected remote transport. |
50+| `expired` | The session or attachment resume window is no longer available. |
30 51
31-## UDP transport health diagnostics52+In raw terminal mode, state changes appear as vev status text rather than bytes
53+written into the PTY's output stream.
32 54
33-At debug logging, UDP transport-health lines report only state, elapsed progress ages, and bounded transport counters; they do not include payloads, addresses, keys, or identities. For a real remote attempt, `scripts/debug-remote-attach.sh --udp-health user@host` stores redacted transport-health lines and Linux `ss -u -m` socket-memory counters, then prints the exact collected file paths.55+## Diagnostics and checks
34 56
35-## Manual checks57+At debug level, transport-health logs contain only state, elapsed progress ages,
58+and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
59+collects redacted health lines and Linux `ss -u -m` socket-memory counters.
36 60
37-Useful scenarios for validating resilience behavior:61+Useful checks are:
38 62
39 - block and unblock the configured UDP port range;63 - block and unblock the configured UDP port range;
40 - move between networks or change VPN state;64 - move between networks or change VPN state;
41-- suspend and resume the client machine;65+- suspend and resume the attachment machine;
42-- retry `vev attach user@host:session` for the same named session;66+- reconnect to the same named session;
43-- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.67+- verify that a second attachment retains its independent view while the first
68+ reconnects.
44 69
45-Keep hostnames, usernames, and local paths out of shared logs and screenshots.70+Keep hostnames, usernames, keys, and local paths out of shared logs and
71+screenshots.
M docs/terminal.md +7 -1
@@ -8,12 +8,18 @@ When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORT
88
99 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
1010
11-For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.
11+The attaching client's exported environment is sent in the connection handshake and becomes the session's source of truth for future PTY children, including remote attaches. A later attachment updates that shared environment for children created afterward; already-running processes keep their original environment.
1212
1313 ## VT compatibility
1414
1515 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
1616
17+## Session content size
18+
19+The first valid attachment establishes the session's fixed PTY content size. Each
20+attachment may have a different outer window and viewport; resizing it changes
21+only its attachment-local presentation and does not resize shared PTYs.
22+
1723 ## Recovery
1824
1925 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.
@@ -8,12 +8,18 @@ When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORT
8 8
9 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.9 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
10 10
11-For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.11+The attaching client's exported environment is sent in the connection handshake and becomes the session's source of truth for future PTY children, including remote attaches. A later attachment updates that shared environment for children created afterward; already-running processes keep their original environment.
12 12
13 ## VT compatibility13 ## VT compatibility
14 14
15 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.15 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
16 16
17+## Session content size
18+
19+The first valid attachment establishes the session's fixed PTY content size. Each
20+attachment may have a different outer window and viewport; resizing it changes
21+only its attachment-local presentation and does not resize shared PTYs.
22+
17 ## Recovery23 ## Recovery
18 24
19 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.25 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.
A internal/app/final_acceptance_test.go +178 -0
@@ -0,0 +1,178 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/daemon"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
16+ t.Helper()
17+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))
18+}
19+
20+func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
21+ t.Helper()
22+ deadline := time.NewTimer(5 * time.Second)
23+ defer deadline.Stop()
24+ for {
25+ select {
26+ case frame, ok := <-p.ch:
27+ require.True(t, ok, "connection closed before command result")
28+ if frame.Type != ports.MsgCommandResult {
29+ continue
30+ }
31+ result, err := ports.UnmarshalCommandResult(frame.Payload)
32+ require.NoError(t, err)
33+ if result.RequestID == requestID {
34+ return result
35+ }
36+ case <-deadline.C:
37+ t.Fatal("timed out waiting for command result")
38+ }
39+ }
40+}
41+
42+func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {
43+ t.Helper()
44+ deadline := time.NewTimer(5 * time.Second)
45+ defer deadline.Stop()
46+ for {
47+ select {
48+ case frame, ok := <-p.ch:
49+ require.True(t, ok, "connection closed before output")
50+ if frame.Type != ports.MsgOutput {
51+ continue
52+ }
53+ output, err := ports.UnmarshalOutput(frame.Payload)
54+ require.NoError(t, err)
55+ if predicate(output) {
56+ return output
57+ }
58+ case <-deadline.C:
59+ t.Fatal("timed out waiting for output")
60+ }
61+ }
62+}
63+
64+func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {
65+ t.Helper()
66+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
67+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
68+ })
69+ require.NoError(t, err)
70+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}))
71+ return awaitAcceptanceCommand(t, p, requestID)
72+}
73+
74+func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
75+ size := domain.Size{Cols: 80, Rows: 24}
76+ dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
77+ first, firstPump := attach(t, dir, ports.IntentNew, "shared", size)
78+ second, secondPump := attach(t, dir, ports.IntentAttach, "shared", size)
79+ t.Cleanup(func() { _ = first.Close() })
80+ t.Cleanup(func() { _ = second.Close() })
81+
82+ sendAcceptanceInput(t, first, "SHARED_INPUT\n")
83+ awaitText(t, firstPump, size, "SHARED_INPUT")
84+ awaitText(t, secondPump, size, "SHARED_INPUT")
85+
86+ // A tab opened by the first attachment is shared session topology, but its
87+ // selected window is not. The second attachment stays on the original tab.
88+ sendAcceptanceInput(t, first, "\x1b ")
89+ awaitText(t, firstPump, size, "Commands")
90+ sendAcceptanceInput(t, first, "CNT\r")
91+ awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")
92+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
93+
94+ sendAcceptanceInput(t, first, "\x1b ")
95+ awaitText(t, firstPump, size, "Commands")
96+ sendAcceptanceInput(t, first, "SPR\r")
97+ sendAcceptanceInput(t, first, "FIRST_PANE_INPUT\n")
98+ awaitText(t, firstPump, size, "FIRST_PANE_INPUT")
99+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
100+ sendAcceptanceInput(t, second, "SECOND_TAB_INPUT\n")
101+ awaitText(t, secondPump, size, "SECOND_TAB_INPUT")
102+
103+ // Overlays are attachment-owned: a palette on one wire and copy mode on
104+ // the other must not repaint the peer's viewport.
105+ sendAcceptanceInput(t, first, "\x1b ")
106+ awaitText(t, firstPump, size, "Commands")
107+ assertNoTextAfterInput(t, secondPump, size, "Commands")
108+ sendAcceptanceInput(t, first, "\x1b")
109+
110+ sendAcceptanceInput(t, second, "\x1b ")
111+ awaitText(t, secondPump, size, "Commands")
112+ sendAcceptanceInput(t, second, "VIS\r")
113+ awaitText(t, secondPump, size, "[SCROLL]")
114+ assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")
115+
116+ // A resize and an output reset are scoped to one attachment output stream;
117+ // the shared tab's PTY/content geometry and the peer stream remain intact.
118+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})}))
119+ resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })
120+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)
121+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
122+
123+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
124+ reset := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Base == 0 && output.Full })
125+ require.True(t, reset.Full)
126+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
127+
128+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}))
129+ deadline := time.NewTimer(5 * time.Second)
130+ defer deadline.Stop()
131+ for {
132+ select {
133+ case frame := <-firstPump.ch:
134+ if frame.Type == ports.MsgDetached {
135+ goto detached
136+ }
137+ case <-deadline.C:
138+ t.Fatal("timed out waiting for first attachment detach")
139+ }
140+ }
141+
142+detached:
143+ sendAcceptanceInput(t, second, "q")
144+ sendAcceptanceInput(t, second, "PEER_SURVIVES\n")
145+ awaitText(t, secondPump, size, "PEER_SURVIVES")
146+}
147+
148+func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) {
149+ size := domain.Size{Cols: 80, Rows: 24}
150+ dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
151+ first, firstPump := attach(t, dir, ports.IntentNew, "commands", size)
152+ second, secondPump := attach(t, dir, ports.IntentAttach, "commands", size)
153+ t.Cleanup(func() { _ = first.Close() })
154+ t.Cleanup(func() { _ = second.Close() })
155+
156+ firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")
157+ secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")
158+ require.True(t, firstResult.OK, firstResult.Text)
159+ require.True(t, secondResult.OK, secondResult.Text)
160+ require.Empty(t, firstResult.Output)
161+ require.Empty(t, secondResult.Output)
162+ require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)
163+
164+ // Drain any asynchronous output before proving that a reset on one real
165+ // IPC transport does not create a reset frame on the other.
166+ for {
167+ select {
168+ case <-secondPump.ch:
169+ default:
170+ goto drained
171+ }
172+ }
173+
174+drained:
175+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
176+ awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Full && output.Base == 0 })
177+ assertNoTextAfterInput(t, secondPump, size, "Commands")
178+}
@@ -0,0 +1,178 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/daemon"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
16+ t.Helper()
17+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))
18+}
19+
20+func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
21+ t.Helper()
22+ deadline := time.NewTimer(5 * time.Second)
23+ defer deadline.Stop()
24+ for {
25+ select {
26+ case frame, ok := <-p.ch:
27+ require.True(t, ok, "connection closed before command result")
28+ if frame.Type != ports.MsgCommandResult {
29+ continue
30+ }
31+ result, err := ports.UnmarshalCommandResult(frame.Payload)
32+ require.NoError(t, err)
33+ if result.RequestID == requestID {
34+ return result
35+ }
36+ case <-deadline.C:
37+ t.Fatal("timed out waiting for command result")
38+ }
39+ }
40+}
41+
42+func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {
43+ t.Helper()
44+ deadline := time.NewTimer(5 * time.Second)
45+ defer deadline.Stop()
46+ for {
47+ select {
48+ case frame, ok := <-p.ch:
49+ require.True(t, ok, "connection closed before output")
50+ if frame.Type != ports.MsgOutput {
51+ continue
52+ }
53+ output, err := ports.UnmarshalOutput(frame.Payload)
54+ require.NoError(t, err)
55+ if predicate(output) {
56+ return output
57+ }
58+ case <-deadline.C:
59+ t.Fatal("timed out waiting for output")
60+ }
61+ }
62+}
63+
64+func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {
65+ t.Helper()
66+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
67+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
68+ })
69+ require.NoError(t, err)
70+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}))
71+ return awaitAcceptanceCommand(t, p, requestID)
72+}
73+
74+func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
75+ size := domain.Size{Cols: 80, Rows: 24}
76+ dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
77+ first, firstPump := attach(t, dir, ports.IntentNew, "shared", size)
78+ second, secondPump := attach(t, dir, ports.IntentAttach, "shared", size)
79+ t.Cleanup(func() { _ = first.Close() })
80+ t.Cleanup(func() { _ = second.Close() })
81+
82+ sendAcceptanceInput(t, first, "SHARED_INPUT\n")
83+ awaitText(t, firstPump, size, "SHARED_INPUT")
84+ awaitText(t, secondPump, size, "SHARED_INPUT")
85+
86+ // A tab opened by the first attachment is shared session topology, but its
87+ // selected window is not. The second attachment stays on the original tab.
88+ sendAcceptanceInput(t, first, "\x1b ")
89+ awaitText(t, firstPump, size, "Commands")
90+ sendAcceptanceInput(t, first, "CNT\r")
91+ awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")
92+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
93+
94+ sendAcceptanceInput(t, first, "\x1b ")
95+ awaitText(t, firstPump, size, "Commands")
96+ sendAcceptanceInput(t, first, "SPR\r")
97+ sendAcceptanceInput(t, first, "FIRST_PANE_INPUT\n")
98+ awaitText(t, firstPump, size, "FIRST_PANE_INPUT")
99+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
100+ sendAcceptanceInput(t, second, "SECOND_TAB_INPUT\n")
101+ awaitText(t, secondPump, size, "SECOND_TAB_INPUT")
102+
103+ // Overlays are attachment-owned: a palette on one wire and copy mode on
104+ // the other must not repaint the peer's viewport.
105+ sendAcceptanceInput(t, first, "\x1b ")
106+ awaitText(t, firstPump, size, "Commands")
107+ assertNoTextAfterInput(t, secondPump, size, "Commands")
108+ sendAcceptanceInput(t, first, "\x1b")
109+
110+ sendAcceptanceInput(t, second, "\x1b ")
111+ awaitText(t, secondPump, size, "Commands")
112+ sendAcceptanceInput(t, second, "VIS\r")
113+ awaitText(t, secondPump, size, "[SCROLL]")
114+ assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")
115+
116+ // A resize and an output reset are scoped to one attachment output stream;
117+ // the shared tab's PTY/content geometry and the peer stream remain intact.
118+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})}))
119+ resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })
120+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)
121+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
122+
123+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
124+ reset := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Base == 0 && output.Full })
125+ require.True(t, reset.Full)
126+ assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
127+
128+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}))
129+ deadline := time.NewTimer(5 * time.Second)
130+ defer deadline.Stop()
131+ for {
132+ select {
133+ case frame := <-firstPump.ch:
134+ if frame.Type == ports.MsgDetached {
135+ goto detached
136+ }
137+ case <-deadline.C:
138+ t.Fatal("timed out waiting for first attachment detach")
139+ }
140+ }
141+
142+detached:
143+ sendAcceptanceInput(t, second, "q")
144+ sendAcceptanceInput(t, second, "PEER_SURVIVES\n")
145+ awaitText(t, secondPump, size, "PEER_SURVIVES")
146+}
147+
148+func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) {
149+ size := domain.Size{Cols: 80, Rows: 24}
150+ dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
151+ first, firstPump := attach(t, dir, ports.IntentNew, "commands", size)
152+ second, secondPump := attach(t, dir, ports.IntentAttach, "commands", size)
153+ t.Cleanup(func() { _ = first.Close() })
154+ t.Cleanup(func() { _ = second.Close() })
155+
156+ firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")
157+ secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")
158+ require.True(t, firstResult.OK, firstResult.Text)
159+ require.True(t, secondResult.OK, secondResult.Text)
160+ require.Empty(t, firstResult.Output)
161+ require.Empty(t, secondResult.Output)
162+ require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)
163+
164+ // Drain any asynchronous output before proving that a reset on one real
165+ // IPC transport does not create a reset frame on the other.
166+ for {
167+ select {
168+ case <-secondPump.ch:
169+ default:
170+ goto drained
171+ }
172+ }
173+
174+drained:
175+ require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
176+ awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Full && output.Base == 0 })
177+ assertNoTextAfterInput(t, secondPump, size, "Commands")
178+}
A internal/app/remote_final_acceptance_test.go +165 -0
@@ -0,0 +1,165 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "log/slog"
10+ "net"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/vev/internal/adapters/sshstdio"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/usecase/client"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+type acceptanceRemoteFactory struct {
22+ mu sync.Mutex
23+ calls []string
24+ handoff map[string]ports.AttachTarget
25+ outputs chan string
26+}
27+
28+func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
29+ f.mu.Lock()
30+ f.calls = append(f.calls, target)
31+ targetHandoff := f.handoff[target]
32+ f.mu.Unlock()
33+ return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil
34+}
35+
36+type acceptanceRemoteDialer struct {
37+ target string
38+ handoff ports.AttachTarget
39+ outputs chan<- string
40+}
41+
42+func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
43+ clientConn, serverConn := net.Pipe()
44+ clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
45+ serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
46+ go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs)
47+ return clientTransport, nil
48+}
49+
50+func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.AttachTarget, outputs chan<- string) {
51+ defer func() { _ = tr.Close() }()
52+ welcomed := false
53+ for {
54+ frame, err := tr.Recv()
55+ if err != nil {
56+ return
57+ }
58+ if !welcomed {
59+ if frame.Type != ports.MsgHello {
60+ continue
61+ }
62+ if err := tr.Send(ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: target})}); err != nil {
63+ return
64+ }
65+ welcomed = true
66+ continue
67+ }
68+ if handoff.Endpoint != "" {
69+ _ = tr.Send(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(handoff)})
70+ for {
71+ if _, err := tr.Recv(); err != nil {
72+ return
73+ }
74+ }
75+ }
76+ data := []byte("remote:" + target)
77+ if outputs != nil {
78+ outputs <- string(data)
79+ }
80+ if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
81+ Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
82+ })}); err != nil {
83+ return
84+ }
85+ _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
86+ for {
87+ if _, err := tr.Recv(); err != nil {
88+ return
89+ }
90+ }
91+ }
92+}
93+
94+type acceptanceRemoteTerminal struct {
95+ in io.Reader
96+ out bytes.Buffer
97+}
98+
99+func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
100+ return func() error { return nil }, nil
101+}
102+func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {
103+ return domain.Size{Cols: 80, Rows: 24}, nil
104+}
105+func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }
106+func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
107+func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
108+func (*acceptanceRemoteTerminal) Flush() error { return nil }
109+
110+func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
111+ outputs := make(chan string, 4)
112+ factory := &acceptanceRemoteFactory{outputs: outputs}
113+ localCalls := 0
114+ terminals := make([]*acceptanceRemoteTerminal, 0, 3)
115+ deps := runAttachDeps{
116+ localDialer: func() ports.Dialer {
117+ localCalls++
118+ return acceptanceRemoteDialer{}
119+ },
120+ remoteDialerFactory: factory,
121+ selectedRemoteTransport: string(ports.RemoteTransportStdio),
122+ runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
123+ term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
124+ terminals = append(terminals, term)
125+ deps.Terminal = term
126+ deps.Logger = slog.New(slog.DiscardHandler)
127+ deps.Remote = true
128+ result := client.NewRunner(deps).Run(ctx, request)
129+ if request.SessionName == "work" {
130+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
131+ }
132+ return result
133+ },
134+ }
135+
136+ wireTransport, err := (acceptanceRemoteDialer{
137+ target: "picker-wire.example",
138+ handoff: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach},
139+ }).Dial(context.Background())
140+ require.NoError(t, err)
141+ require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
142+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24},
143+ })}))
144+ welcome, err := wireTransport.Recv()
145+ require.NoError(t, err)
146+ require.Equal(t, ports.MsgWelcome, welcome.Type)
147+ require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{})}))
148+ attachTarget, err := wireTransport.Recv()
149+ require.NoError(t, err)
150+ target, err := ports.UnmarshalAttachTarget(attachTarget.Payload)
151+ require.NoError(t, err)
152+ require.Equal(t, ports.MsgAttachTarget, attachTarget.Type)
153+ require.Equal(t, "picked.example", target.Endpoint)
154+ require.NoError(t, wireTransport.Close())
155+
156+ require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "direct", "direct.example", "", nil, deps))
157+ require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "picker.example", "", nil, deps))
158+ factory.mu.Lock()
159+ calls := append([]string(nil), factory.calls...)
160+ factory.mu.Unlock()
161+ require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls)
162+ require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow")
163+ require.Len(t, terminals, 3)
164+ require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs})
165+}
@@ -0,0 +1,165 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "io"
9+ "log/slog"
10+ "net"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/vev/internal/adapters/sshstdio"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ "github.com/bnema/vev/internal/usecase/client"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+type acceptanceRemoteFactory struct {
22+ mu sync.Mutex
23+ calls []string
24+ handoff map[string]ports.AttachTarget
25+ outputs chan string
26+}
27+
28+func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
29+ f.mu.Lock()
30+ f.calls = append(f.calls, target)
31+ targetHandoff := f.handoff[target]
32+ f.mu.Unlock()
33+ return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil
34+}
35+
36+type acceptanceRemoteDialer struct {
37+ target string
38+ handoff ports.AttachTarget
39+ outputs chan<- string
40+}
41+
42+func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
43+ clientConn, serverConn := net.Pipe()
44+ clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
45+ serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
46+ go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs)
47+ return clientTransport, nil
48+}
49+
50+func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.AttachTarget, outputs chan<- string) {
51+ defer func() { _ = tr.Close() }()
52+ welcomed := false
53+ for {
54+ frame, err := tr.Recv()
55+ if err != nil {
56+ return
57+ }
58+ if !welcomed {
59+ if frame.Type != ports.MsgHello {
60+ continue
61+ }
62+ if err := tr.Send(ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: target})}); err != nil {
63+ return
64+ }
65+ welcomed = true
66+ continue
67+ }
68+ if handoff.Endpoint != "" {
69+ _ = tr.Send(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(handoff)})
70+ for {
71+ if _, err := tr.Recv(); err != nil {
72+ return
73+ }
74+ }
75+ }
76+ data := []byte("remote:" + target)
77+ if outputs != nil {
78+ outputs <- string(data)
79+ }
80+ if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
81+ Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
82+ })}); err != nil {
83+ return
84+ }
85+ _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
86+ for {
87+ if _, err := tr.Recv(); err != nil {
88+ return
89+ }
90+ }
91+ }
92+}
93+
94+type acceptanceRemoteTerminal struct {
95+ in io.Reader
96+ out bytes.Buffer
97+}
98+
99+func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
100+ return func() error { return nil }, nil
101+}
102+func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {
103+ return domain.Size{Cols: 80, Rows: 24}, nil
104+}
105+func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }
106+func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
107+func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
108+func (*acceptanceRemoteTerminal) Flush() error { return nil }
109+
110+func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
111+ outputs := make(chan string, 4)
112+ factory := &acceptanceRemoteFactory{outputs: outputs}
113+ localCalls := 0
114+ terminals := make([]*acceptanceRemoteTerminal, 0, 3)
115+ deps := runAttachDeps{
116+ localDialer: func() ports.Dialer {
117+ localCalls++
118+ return acceptanceRemoteDialer{}
119+ },
120+ remoteDialerFactory: factory,
121+ selectedRemoteTransport: string(ports.RemoteTransportStdio),
122+ runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
123+ term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
124+ terminals = append(terminals, term)
125+ deps.Terminal = term
126+ deps.Logger = slog.New(slog.DiscardHandler)
127+ deps.Remote = true
128+ result := client.NewRunner(deps).Run(ctx, request)
129+ if request.SessionName == "work" {
130+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
131+ }
132+ return result
133+ },
134+ }
135+
136+ wireTransport, err := (acceptanceRemoteDialer{
137+ target: "picker-wire.example",
138+ handoff: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach},
139+ }).Dial(context.Background())
140+ require.NoError(t, err)
141+ require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
142+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24},
143+ })}))
144+ welcome, err := wireTransport.Recv()
145+ require.NoError(t, err)
146+ require.Equal(t, ports.MsgWelcome, welcome.Type)
147+ require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{})}))
148+ attachTarget, err := wireTransport.Recv()
149+ require.NoError(t, err)
150+ target, err := ports.UnmarshalAttachTarget(attachTarget.Payload)
151+ require.NoError(t, err)
152+ require.Equal(t, ports.MsgAttachTarget, attachTarget.Type)
153+ require.Equal(t, "picked.example", target.Endpoint)
154+ require.NoError(t, wireTransport.Close())
155+
156+ require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "direct", "direct.example", "", nil, deps))
157+ require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "picker.example", "", nil, deps))
158+ factory.mu.Lock()
159+ calls := append([]string(nil), factory.calls...)
160+ factory.mu.Unlock()
161+ require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls)
162+ require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow")
163+ require.Len(t, terminals, 3)
164+ require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs})
165+}
M internal/ports/wire.go +1 -1
@@ -214,7 +214,7 @@ type Output struct {
214214 }
215215
216216 // AttachTarget identifies the endpoint and session selected for a new
217-// attachment. It deliberately carries no viewport, resume, or proxy state.
217+// attachment. It deliberately carries no viewport, resume, or session state.
218218 type AttachTarget struct {
219219 Endpoint string
220220 Session string
@@ -214,7 +214,7 @@ type Output struct {
214 }214 }
215 215
216 // AttachTarget identifies the endpoint and session selected for a new216 // AttachTarget identifies the endpoint and session selected for a new
217-// attachment. It deliberately carries no viewport, resume, or proxy state.217+// attachment. It deliberately carries no viewport, resume, or session state.
218 type AttachTarget struct {218 type AttachTarget struct {
219 Endpoint string219 Endpoint string
220 Session string220 Session string
M internal/usecase/daemon/client_frame_routing.go +1 -1
@@ -221,7 +221,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
221221 return result
222222 }
223223
224-// resetOutput rebases only the exact proxied attachment and transport admitted
224+// resetOutput rebases only the exact attachment and transport admitted
225225 // by the frame router. sendMu serializes the rebase with every output send;
226226 // transport revalidation under that lock rejects a link replaced while waiting.
227227 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
@@ -221,7 +221,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
221 return result221 return result
222 }222 }
223 223
224-// resetOutput rebases only the exact proxied attachment and transport admitted224+// resetOutput rebases only the exact attachment and transport admitted
225 // by the frame router. sendMu serializes the rebase with every output send;225 // by the frame router. sendMu serializes the rebase with every output send;
226 // transport revalidation under that lock rejects a link replaced while waiting.226 // transport revalidation under that lock rejects a link replaced while waiting.
227 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {227 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
A internal/usecase/daemon/final_acceptance_test.go +88 -0
@@ -0,0 +1,88 @@
1+package daemon
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/pkg/vt"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing.T) {
13+ d, sess, first, _ := newManualSessionWithPTYs(t, newQuietPTY())
14+ first.clientID[0] = 1
15+ firstTab := sess.tabs[0]
16+ firstTab.stableID = "tab-first"
17+ firstTab.panes[firstTab.focusedPane().id].stableID = "pane-first"
18+
19+ secondTransport, _ := newCapturingTransport(t)
20+ second := &attachedClient{
21+ tr: secondTransport,
22+ output: newOutputStateStream(),
23+ size: domain.Size{Cols: 80, Rows: 24},
24+ }
25+ second.initOverlays()
26+ second.clientID[0] = 2
27+ second.setSession(sess)
28+
29+ secondTab := newTabWithStableID("tab-second", "pane-second", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
30+ secondTab.ctx, secondTab.cancel = sess.ctx, func() {}
31+ publishTiledPaneOwners(sess, secondTab)
32+ require.NoError(t, sess.runMutation(func() error {
33+ sess.mu.Lock()
34+ defer sess.mu.Unlock()
35+ sess.tabs = append(sess.tabs, secondTab)
36+ return nil
37+ }))
38+ require.True(t, sess.registerAttachment(second))
39+ require.True(t, sess.selectAttachmentTab(first, domain.TabStableID(firstTab.stableID)))
40+ require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(secondTab.stableID)))
41+
42+ firstBefore := first.viewSnapshot()
43+ secondBefore := second.viewSnapshot()
44+ require.True(t, sess.updateAttachmentView(first, func(view *attachmentView) {
45+ view.windowTop = 7
46+ view.windowRows = 12
47+ view.bookmark = 11
48+ view.liveBottom = false
49+ }))
50+ gotFirst := first.viewSnapshot()
51+ gotSecond := second.viewSnapshot()
52+ require.Equal(t, domain.TabStableID(firstTab.stableID), gotFirst.tabID)
53+ require.Equal(t, 7, gotFirst.windowTop)
54+ require.Equal(t, vt.RowID(11), gotFirst.bookmark)
55+ require.False(t, gotFirst.liveBottom)
56+ require.Equal(t, secondBefore, gotSecond, "first attachment view mutation leaked to peer")
57+ require.NotEqual(t, firstBefore.revision, gotFirst.revision)
58+
59+ rc := d.attachCoordinator(sess, nil, first, true)
60+ d.attachCoordinator(sess, nil, second, true)
61+ firstToken := sess.attachmentToken(first, first.transport())
62+ firstToken.lease = rc.attachmentLease(first)
63+ first.publishAttachmentCapability(firstToken)
64+ secondToken := sess.attachmentToken(second, second.transport())
65+ secondToken.lease = rc.attachmentLease(second)
66+ second.publishAttachmentCapability(secondToken)
67+ firstEpoch := first.output.currentEpoch()
68+ secondEpoch := second.output.currentEpoch()
69+
70+ d.handleAttachmentClientFrame(secondToken, ports.Frame{
71+ Type: ports.MsgOutputResetRequest,
72+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
73+ })
74+ require.Greater(t, second.output.currentEpoch(), secondEpoch)
75+ require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary")
76+
77+ require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40}))
78+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
79+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
80+ sess.mu.Lock()
81+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, sess.tabs[0].size, "attachment resize mutated shared session content")
82+ sess.mu.Unlock()
83+
84+ d.clientGone(sess, first, first.transport(), true)
85+ require.NotContains(t, sess.snapshotAttachments(), first)
86+ require.Contains(t, sess.snapshotAttachments(), second, "detaching one attachment removed its peer")
87+ require.Same(t, sess, second.currentSession())
88+}
@@ -0,0 +1,88 @@
1+package daemon
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/pkg/vt"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing.T) {
13+ d, sess, first, _ := newManualSessionWithPTYs(t, newQuietPTY())
14+ first.clientID[0] = 1
15+ firstTab := sess.tabs[0]
16+ firstTab.stableID = "tab-first"
17+ firstTab.panes[firstTab.focusedPane().id].stableID = "pane-first"
18+
19+ secondTransport, _ := newCapturingTransport(t)
20+ second := &attachedClient{
21+ tr: secondTransport,
22+ output: newOutputStateStream(),
23+ size: domain.Size{Cols: 80, Rows: 24},
24+ }
25+ second.initOverlays()
26+ second.clientID[0] = 2
27+ second.setSession(sess)
28+
29+ secondTab := newTabWithStableID("tab-second", "pane-second", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
30+ secondTab.ctx, secondTab.cancel = sess.ctx, func() {}
31+ publishTiledPaneOwners(sess, secondTab)
32+ require.NoError(t, sess.runMutation(func() error {
33+ sess.mu.Lock()
34+ defer sess.mu.Unlock()
35+ sess.tabs = append(sess.tabs, secondTab)
36+ return nil
37+ }))
38+ require.True(t, sess.registerAttachment(second))
39+ require.True(t, sess.selectAttachmentTab(first, domain.TabStableID(firstTab.stableID)))
40+ require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(secondTab.stableID)))
41+
42+ firstBefore := first.viewSnapshot()
43+ secondBefore := second.viewSnapshot()
44+ require.True(t, sess.updateAttachmentView(first, func(view *attachmentView) {
45+ view.windowTop = 7
46+ view.windowRows = 12
47+ view.bookmark = 11
48+ view.liveBottom = false
49+ }))
50+ gotFirst := first.viewSnapshot()
51+ gotSecond := second.viewSnapshot()
52+ require.Equal(t, domain.TabStableID(firstTab.stableID), gotFirst.tabID)
53+ require.Equal(t, 7, gotFirst.windowTop)
54+ require.Equal(t, vt.RowID(11), gotFirst.bookmark)
55+ require.False(t, gotFirst.liveBottom)
56+ require.Equal(t, secondBefore, gotSecond, "first attachment view mutation leaked to peer")
57+ require.NotEqual(t, firstBefore.revision, gotFirst.revision)
58+
59+ rc := d.attachCoordinator(sess, nil, first, true)
60+ d.attachCoordinator(sess, nil, second, true)
61+ firstToken := sess.attachmentToken(first, first.transport())
62+ firstToken.lease = rc.attachmentLease(first)
63+ first.publishAttachmentCapability(firstToken)
64+ secondToken := sess.attachmentToken(second, second.transport())
65+ secondToken.lease = rc.attachmentLease(second)
66+ second.publishAttachmentCapability(secondToken)
67+ firstEpoch := first.output.currentEpoch()
68+ secondEpoch := second.output.currentEpoch()
69+
70+ d.handleAttachmentClientFrame(secondToken, ports.Frame{
71+ Type: ports.MsgOutputResetRequest,
72+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
73+ })
74+ require.Greater(t, second.output.currentEpoch(), secondEpoch)
75+ require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary")
76+
77+ require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40}))
78+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
79+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
80+ sess.mu.Lock()
81+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, sess.tabs[0].size, "attachment resize mutated shared session content")
82+ sess.mu.Unlock()
83+
84+ d.clientGone(sess, first, first.transport(), true)
85+ require.NotContains(t, sess.snapshotAttachments(), first)
86+ require.Contains(t, sess.snapshotAttachments(), second, "detaching one attachment removed its peer")
87+ require.Same(t, sess, second.currentSession())
88+}
M internal/usecase/daemon/resume_test.go +1 -1
@@ -874,7 +874,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
874874 }
875875
876876 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
877-// when the attachment is no longer the exact active or snatched owner, while
877+// when the attachment is no longer the exact registered owner, while
878878 // still advertising for both live roles.
879879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
880880 pty, release := newBlockingPTY(t)
@@ -874,7 +874,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
874 }874 }
875 875
876 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection876 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
877-// when the attachment is no longer the exact active or snatched owner, while877+// when the attachment is no longer the exact registered owner, while
878 // still advertising for both live roles.878 // still advertising for both live roles.
879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
880 pty, release := newBlockingPTY(t)880 pty, release := newBlockingPTY(t)
A pkg/vt/history_final_acceptance_test.go +57 -0
@@ -0,0 +1,57 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) {
11+ history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2})
12+ for _, text := range []string{"live-1", "live-2", "live-3"} {
13+ require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))
14+ }
15+
16+ beforeScroll := history.View()
17+ bookmark := beforeScroll.RowID(1)
18+ immutable := beforeScroll.Row(1)
19+ immutable[0].Rune = 'X'
20+ require.Equal(t, 'l', beforeScroll.Row(1)[0].Rune, "immutable history view exposed mutable cells")
21+
22+ require.NoError(t, history.Append(historyAcceptanceRow("live-4"), LineBound{End: 6}))
23+ afterScroll := history.View()
24+ require.Equal(t, 3, afterScroll.Len())
25+ require.Equal(t, 0, afterScroll.FindRowID(bookmark), "bookmark row was not retained after one live scroll")
26+ require.Equal(t, "live-2", historyAcceptanceText(afterScroll.Row(0)))
27+ require.Equal(t, "live-1", historyAcceptanceText(beforeScroll.Row(0)), "retained view changed after eviction")
28+
29+ sealed, tail, err := MarshalSealedHistory(history.SealAndView())
30+ require.NoError(t, err)
31+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail)
32+ require.NoError(t, err)
33+ restoredView := restored.View()
34+ require.Equal(t, afterScroll.Len(), restoredView.Len())
35+ require.Equal(t, bookmark, restoredView.RowID(0), "bookmark row identity changed during restore")
36+ require.Equal(t, "live-2", historyAcceptanceText(restoredView.Row(0)))
37+
38+ next := restoredView.NextRowID()
39+ require.NoError(t, restored.Append(historyAcceptanceRow("live-5"), LineBound{End: 6}))
40+ require.Greater(t, restored.View().NextRowID(), next)
41+}
42+
43+func historyAcceptanceRow(text string) []renderer.Cell {
44+ row := make([]renderer.Cell, len(text))
45+ for i, r := range text {
46+ row[i] = renderer.Cell{Rune: r}
47+ }
48+ return row
49+}
50+
51+func historyAcceptanceText(row []renderer.Cell) string {
52+ text := make([]rune, len(row))
53+ for i, cell := range row {
54+ text[i] = cell.Rune
55+ }
56+ return string(text)
57+}
@@ -0,0 +1,57 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) {
11+ history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2})
12+ for _, text := range []string{"live-1", "live-2", "live-3"} {
13+ require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))
14+ }
15+
16+ beforeScroll := history.View()
17+ bookmark := beforeScroll.RowID(1)
18+ immutable := beforeScroll.Row(1)
19+ immutable[0].Rune = 'X'
20+ require.Equal(t, 'l', beforeScroll.Row(1)[0].Rune, "immutable history view exposed mutable cells")
21+
22+ require.NoError(t, history.Append(historyAcceptanceRow("live-4"), LineBound{End: 6}))
23+ afterScroll := history.View()
24+ require.Equal(t, 3, afterScroll.Len())
25+ require.Equal(t, 0, afterScroll.FindRowID(bookmark), "bookmark row was not retained after one live scroll")
26+ require.Equal(t, "live-2", historyAcceptanceText(afterScroll.Row(0)))
27+ require.Equal(t, "live-1", historyAcceptanceText(beforeScroll.Row(0)), "retained view changed after eviction")
28+
29+ sealed, tail, err := MarshalSealedHistory(history.SealAndView())
30+ require.NoError(t, err)
31+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail)
32+ require.NoError(t, err)
33+ restoredView := restored.View()
34+ require.Equal(t, afterScroll.Len(), restoredView.Len())
35+ require.Equal(t, bookmark, restoredView.RowID(0), "bookmark row identity changed during restore")
36+ require.Equal(t, "live-2", historyAcceptanceText(restoredView.Row(0)))
37+
38+ next := restoredView.NextRowID()
39+ require.NoError(t, restored.Append(historyAcceptanceRow("live-5"), LineBound{End: 6}))
40+ require.Greater(t, restored.View().NextRowID(), next)
41+}
42+
43+func historyAcceptanceRow(text string) []renderer.Cell {
44+ row := make([]renderer.Cell, len(text))
45+ for i, r := range text {
46+ row[i] = renderer.Cell{Rune: r}
47+ }
48+ return row
49+}
50+
51+func historyAcceptanceText(row []renderer.Cell) string {
52+ text := make([]rune, len(row))
53+ for i, cell := range row {
54+ text[i] = cell.Rune
55+ }
56+ return string(text)
57+}

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 2

Tags 13

↑↓ moveenter openesc close