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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 64 | 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 | 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 | 68 | - Version negotiation requires strict equality. | |
| 69 | 69 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | |
| 70 | 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 | 74 | - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted. | |
| 75 | 75 | - Named sessions survive headless and persist across daemon restarts. | |
| 76 | 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. | |
| 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) | |||
| 51 | 51 | ||
| 52 | 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 | 56 | ## Keys | |
| 57 | 57 | ||
| @@ -73,13 +73,13 @@ The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, | |||
| 73 | 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 | 78 | ```sh | |
| 79 | 79 | 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 | 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 | 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 | 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 | ||
| @@ -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 | ## Keys | 56 | ## 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 | ```sh | 78 | ```sh | ||
| 79 | sudo ufw allow 61000:61023/udp | 79 | 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 @@ | |||
| 1 | 1 | # Performance measurement | |
| 2 | 2 | ||
| 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 | |
| 18 | 27 | ||
| 19 | 28 | 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: | |
| 24 | 34 | ||
| 25 | 35 | ```json | |
| 26 | 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-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. | |
| 58 | 45 | ||
| 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. | |
| 73 | 51 | ||
| 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 | |
| 77 | 53 | ||
| 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: | |
| 93 | 56 | ||
| 94 | 57 | ```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 | |
| 98 | 65 | ``` | |
| 99 | 66 | ||
| 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. | |
| 102 | 70 | ||
| 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. | |
| 104 | 78 | ||
| 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: | |
| 110 | 80 | ||
| 111 | 81 | ```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 | |
| 130 | 84 | ``` | |
| 131 | 85 | ||
| 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. | |
| 172 | 89 | ||
| 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 | |
| 180 | 91 | ||
| 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: | |
| 210 | 94 | ||
| 211 | 95 | ```sh | |
| 212 | 96 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| @@ -1,212 +1,96 @@ | |||||
| 1 | # Performance measurement | 1 | # Performance measurement | ||
| 2 | 2 | ||||
| 3 | - | ## S5a transport observability | 3 | + | 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-owned | 5 | + | rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`. |
| 6 | - | end-to-end boundaries without changing rendering, pacing, transport policy, wire | 6 | + | |
| 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, UDP | 11 | + | - 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 are | 13 | + | - 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 domains | 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 | |||
| 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 | ```json | 35 | ```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-local | 39 | + | 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 every | 41 | + | share that observer. The harness owns a separate monotonic clock for the |
| 32 | - | launched role, identifying its exclusive trace path and process/scenario/run | 42 | + | input-injection to successfully-flushed-terminal-bytes boundary. Ticks from |
| 33 | - | mapping. Components in one OS | 43 | + | different processes or clock domains are never ordered or subtracted. Records |
| 34 | - | process share that observer. A spawned daemon is deliberately stripped of | 44 | + | 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 | - | ```sh | 46 | + | 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-clone | 47 | + | 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 1f2c802d0707785cab979134db6a95645250a82c | 48 | + | 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-clone | 49 | + | 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 2b48abf9b481c3dbdc371ba13c15cf06051d9df0 | 50 | + | 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 on | 52 | + | ## Running the matrix |
| 75 | - | Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit | |||
| 76 | - | `1f2c802d0707785cab979134db6a95645250a82c`: | |||
| 77 | 53 | ||||
| 78 | - | ```sh | 54 | + | Use a clean build and at least a 10-second warmup, a 30-second measured |
| 79 | - | cd /tmp/vev-perf-baseline-clone | 55 | + | 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 | ```sh | 57 | ```sh | ||
| 95 | - | cd /tmp/vev-perf-candidate-2b48abf9-clone | 58 | + | 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.txt | 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 | |||
| 98 | ``` | 65 | ``` | ||
| 99 | 66 | ||||
| 100 | - | The candidate guardrail completed with Go 1.26.5; its ten-repetition output is | 67 | + | 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 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. | |||
| 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 | ```sh | 81 | ```sh | ||
| 112 | - | for bin in baseline candidate; do | 82 | + | jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json |
| 113 | - | case "$bin" in | 83 | + | 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/`. Each | 86 | + | 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 a | 87 | + | validates the complete manifest before selecting that scenario. Do not commit |
| 134 | - | summary. The common completed results below are harness end-to-end medians | 88 | + | 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 | - | ```sh | 90 | + | ## 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 most | 92 | + | 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 input | 93 | + | 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 | ```sh | 95 | ```sh | ||
| 212 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | 96 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | ||
M docs/remote-resilience.md +53 -27
| @@ -1,45 +1,71 @@ | |||
| 1 | 1 | # 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 model | |
| 15 | + | ## Connection limits | |
| 8 | 16 | ||
| 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. | |
| 13 | 25 | ||
| 14 | - | ## Recovery states | |
| 26 | + | ## 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 | | |
| 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. | |
| 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 diagnostics | |
| 52 | + | 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 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. | |
| 36 | 60 | ||
| 37 | - | Useful scenarios for validating resilience behavior: | |
| 61 | + | Useful checks are: | |
| 38 | 62 | ||
| 39 | 63 | - block and unblock the configured UDP port range; | |
| 40 | 64 | - 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. | |
| 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. | |
| @@ -1,45 +1,71 @@ | |||||
| 1 | # Remote resilience | 1 | # 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 model | 15 | + | ## 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 states | 26 | + | ## 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 diagnostics | 52 | + | 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 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. | |||
| 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 | |||
| 8 | 8 | ||
| 9 | 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 | 13 | ## VT compatibility | |
| 14 | 14 | ||
| 15 | 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 | 23 | ## Recovery | |
| 18 | 24 | ||
| 19 | 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. | |
| @@ -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 compatibility | 13 | ## 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 | ## Recovery | 23 | ## 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 { | |||
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | // 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 | 218 | type AttachTarget struct { | |
| 219 | 219 | Endpoint string | |
| 220 | 220 | Session string | |
| @@ -214,7 +214,7 @@ type Output struct { | |||||
| 214 | } | 214 | } | ||
| 215 | 215 | ||||
| 216 | // AttachTarget identifies the endpoint and session selected for a new | 216 | // 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 string | 219 | Endpoint string | ||
| 220 | Session string | 220 | Session string | ||
M internal/usecase/daemon/client_frame_routing.go +1 -1
| @@ -221,7 +221,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 221 | 221 | return result | |
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | - | // resetOutput rebases only the exact proxied attachment and transport admitted | |
| 224 | + | // resetOutput rebases only the exact attachment and transport admitted | |
| 225 | 225 | // by the frame router. sendMu serializes the rebase with every output send; | |
| 226 | 226 | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 227 | 227 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |
| @@ -221,7 +221,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||||
| 221 | return result | 221 | return result | ||
| 222 | } | 222 | } | ||
| 223 | 223 | ||||
| 224 | - | // resetOutput rebases only the exact proxied attachment and transport admitted | 224 | + | // 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) { | |||
| 874 | 874 | } | |
| 875 | 875 | ||
| 876 | 876 | // 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 | |
| 878 | 878 | // still advertising for both live roles. | |
| 879 | 879 | func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) { | |
| 880 | 880 | pty, release := newBlockingPTY(t) | |
| @@ -874,7 +874,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) { | |||||
| 874 | } | 874 | } | ||
| 875 | 875 | ||||
| 876 | // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection | 876 | // 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 |
| 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 | + | } | |||