Merge pull request #176 from bnema/chore/finalize-unified-session-access
chore(release): finalize unified session access
80 files · +1836 -748
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. | |
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 | ||
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 | |
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. | |
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. | |
M internal/adapters/sshstdio/transport.go +237 -30
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "fmt" | |
| 13 | 13 | "io" | |
| 14 | 14 | "log/slog" | |
| 15 | + | "os" | |
| 15 | 16 | "os/exec" | |
| 16 | 17 | "strings" | |
| 17 | 18 | "sync" | |
| @@ -33,6 +34,18 @@ var ( | |||
| 33 | 34 | ||
| 34 | 35 | type closeFunc func() error | |
| 35 | 36 | type eofErrFunc func() error | |
| 37 | + | type operationKind uint8 | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | operationSend operationKind = iota | |
| 41 | + | operationReceive | |
| 42 | + | operationKindCount | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | type operationWait struct { | |
| 46 | + | send <-chan struct{} | |
| 47 | + | receive <-chan struct{} | |
| 48 | + | } | |
| 36 | 49 | ||
| 37 | 50 | type Option func(*transport) | |
| 38 | 51 | ||
| @@ -57,7 +70,26 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc | |||
| 57 | 70 | if closeFn == nil { | |
| 58 | 71 | closeFn = func() error { return nil } | |
| 59 | 72 | } | |
| 60 | - | return &transport{r: r, w: w, close: closeFn, eofErr: eofErr} | |
| 73 | + | t := &transport{ | |
| 74 | + | r: r, | |
| 75 | + | w: w, | |
| 76 | + | close: closeFn, | |
| 77 | + | eofErr: eofErr, | |
| 78 | + | done: make(chan struct{}), | |
| 79 | + | } | |
| 80 | + | if file, ok := r.(*os.File); ok && file == os.Stdin { | |
| 81 | + | t.r = newProcessStdinReader(file, t.done) | |
| 82 | + | } else if closer, ok := r.(io.Closer); ok { | |
| 83 | + | t.readerCloser = closer | |
| 84 | + | } else { | |
| 85 | + | t.r = newUnownedReader(r, t.done) | |
| 86 | + | } | |
| 87 | + | if file, ok := w.(*os.File); !ok || file != os.Stdout { | |
| 88 | + | if closer, ok := w.(io.Closer); ok { | |
| 89 | + | t.writerCloser = closer | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | return t | |
| 61 | 93 | } | |
| 62 | 94 | ||
| 63 | 95 | type transport struct { | |
| @@ -65,11 +97,14 @@ type transport struct { | |||
| 65 | 97 | w io.Writer | |
| 66 | 98 | close closeFunc | |
| 67 | 99 | eofErr eofErrFunc | |
| 100 | + | readerCloser io.Closer | |
| 101 | + | writerCloser io.Closer | |
| 102 | + | done chan struct{} | |
| 68 | 103 | observer ports.SerializedRuntimeObserver | |
| 69 | 104 | operationMu sync.Mutex | |
| 70 | - | operationCount int | |
| 105 | + | operationCount [operationKindCount]int | |
| 71 | 106 | closing bool | |
| 72 | - | operationsDone chan struct{} | |
| 107 | + | operationsDone [operationKindCount]chan struct{} | |
| 73 | 108 | closeOnce sync.Once | |
| 74 | 109 | closeErr error | |
| 75 | 110 | ||
| @@ -77,6 +112,154 @@ type transport struct { | |||
| 77 | 112 | readBuf []byte | |
| 78 | 113 | } | |
| 79 | 114 | ||
| 115 | + | type readResult struct { | |
| 116 | + | data []byte | |
| 117 | + | err error | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // unownedReader is the generic cancellation boundary for readers that cannot | |
| 121 | + | // be closed by the transport. The worker may remain blocked until the source | |
| 122 | + | // itself is released; process stdin uses the singleton pump below instead. | |
| 123 | + | // ponytail: arbitrary io.Reader has no cancellation contract, so one fallback | |
| 124 | + | // worker may remain until that source returns; add a cancellable reader port if | |
| 125 | + | // another production unowned source appears. | |
| 126 | + | type unownedReader struct { | |
| 127 | + | r io.Reader | |
| 128 | + | done <-chan struct{} | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func newUnownedReader(r io.Reader, done <-chan struct{}) *unownedReader { | |
| 132 | + | return &unownedReader{r: r, done: done} | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (r *unownedReader) Read(dst []byte) (int, error) { | |
| 136 | + | select { | |
| 137 | + | case <-r.done: | |
| 138 | + | return 0, io.ErrClosedPipe | |
| 139 | + | default: | |
| 140 | + | } | |
| 141 | + | result := make(chan readResult, 1) | |
| 142 | + | go func() { | |
| 143 | + | buf := make([]byte, len(dst)) | |
| 144 | + | n, err := r.r.Read(buf) | |
| 145 | + | result <- readResult{data: buf[:n], err: err} | |
| 146 | + | }() | |
| 147 | + | select { | |
| 148 | + | case <-r.done: | |
| 149 | + | return 0, io.ErrClosedPipe | |
| 150 | + | case result := <-result: | |
| 151 | + | select { | |
| 152 | + | case <-r.done: | |
| 153 | + | return 0, io.ErrClosedPipe | |
| 154 | + | default: | |
| 155 | + | } | |
| 156 | + | copy(dst, result.data) | |
| 157 | + | return len(result.data), result.err | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | type processStdinPump struct { | |
| 162 | + | chunks chan readResult | |
| 163 | + | mu sync.Mutex | |
| 164 | + | buf []byte | |
| 165 | + | err error | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | var processStdinPumps struct { | |
| 169 | + | sync.Mutex | |
| 170 | + | file *os.File | |
| 171 | + | pump *processStdinPump | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // processStdinPumpFor keeps one read worker for the process stdin lifetime. | |
| 175 | + | // Reconnects therefore do not strand one blocked worker per transport. | |
| 176 | + | func processStdinPumpFor(file *os.File) *processStdinPump { | |
| 177 | + | processStdinPumps.Lock() | |
| 178 | + | defer processStdinPumps.Unlock() | |
| 179 | + | if processStdinPumps.pump != nil && processStdinPumps.file == file { | |
| 180 | + | return processStdinPumps.pump | |
| 181 | + | } | |
| 182 | + | pump := &processStdinPump{chunks: make(chan readResult, 1)} | |
| 183 | + | processStdinPumps.file = file | |
| 184 | + | processStdinPumps.pump = pump | |
| 185 | + | go pump.run(file) | |
| 186 | + | return pump | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (p *processStdinPump) run(file *os.File) { | |
| 190 | + | for { | |
| 191 | + | buf := make([]byte, 32*1024) | |
| 192 | + | n, err := file.Read(buf) | |
| 193 | + | if n > 0 { | |
| 194 | + | p.chunks <- readResult{data: append([]byte(nil), buf[:n]...)} | |
| 195 | + | } | |
| 196 | + | if err != nil { | |
| 197 | + | p.chunks <- readResult{err: err} | |
| 198 | + | close(p.chunks) | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (p *processStdinPump) read(done <-chan struct{}, dst []byte) (int, error) { | |
| 205 | + | select { | |
| 206 | + | case <-done: | |
| 207 | + | return 0, io.ErrClosedPipe | |
| 208 | + | default: | |
| 209 | + | } | |
| 210 | + | p.mu.Lock() | |
| 211 | + | if len(p.buf) != 0 { | |
| 212 | + | n := copy(dst, p.buf) | |
| 213 | + | p.buf = p.buf[n:] | |
| 214 | + | p.mu.Unlock() | |
| 215 | + | return n, nil | |
| 216 | + | } | |
| 217 | + | if p.err != nil { | |
| 218 | + | err := p.err | |
| 219 | + | p.mu.Unlock() | |
| 220 | + | return 0, err | |
| 221 | + | } | |
| 222 | + | p.mu.Unlock() | |
| 223 | + | ||
| 224 | + | select { | |
| 225 | + | case <-done: | |
| 226 | + | return 0, io.ErrClosedPipe | |
| 227 | + | case result, ok := <-p.chunks: | |
| 228 | + | if !ok { | |
| 229 | + | return 0, io.EOF | |
| 230 | + | } | |
| 231 | + | if len(result.data) != 0 { | |
| 232 | + | select { | |
| 233 | + | case <-done: | |
| 234 | + | return 0, io.ErrClosedPipe | |
| 235 | + | default: | |
| 236 | + | } | |
| 237 | + | n := copy(dst, result.data) | |
| 238 | + | p.mu.Lock() | |
| 239 | + | p.buf = append(p.buf[:0], result.data[n:]...) | |
| 240 | + | p.mu.Unlock() | |
| 241 | + | return n, nil | |
| 242 | + | } | |
| 243 | + | p.mu.Lock() | |
| 244 | + | p.err = result.err | |
| 245 | + | p.mu.Unlock() | |
| 246 | + | return 0, result.err | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | type processStdinReader struct { | |
| 251 | + | pump *processStdinPump | |
| 252 | + | done <-chan struct{} | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func newProcessStdinReader(file *os.File, done <-chan struct{}) io.Reader { | |
| 256 | + | return processStdinReader{pump: processStdinPumpFor(file), done: done} | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (r processStdinReader) Read(dst []byte) (int, error) { | |
| 260 | + | return r.pump.read(r.done, dst) | |
| 261 | + | } | |
| 262 | + | ||
| 80 | 263 | func (t *transport) Send(f ports.Frame) error { | |
| 81 | 264 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 82 | 265 | n := 1 + len(f.Payload) | |
| @@ -136,7 +319,11 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu | |||
| 136 | 319 | if t.observer == nil { | |
| 137 | 320 | return func(bool) {} | |
| 138 | 321 | } | |
| 139 | - | if !t.beginObservedOperation() { | |
| 322 | + | kind := operationSend | |
| 323 | + | if start == ports.RuntimeAdapterReceiveStart { | |
| 324 | + | kind = operationReceive | |
| 325 | + | } | |
| 326 | + | if !t.beginObservedOperation(kind) { | |
| 140 | 327 | return func(bool) {} | |
| 141 | 328 | } | |
| 142 | 329 | correlation := ports.NewRuntimeCorrelation() | |
| @@ -146,7 +333,7 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu | |||
| 146 | 333 | end = ports.RuntimeAdapterReceiveEnd | |
| 147 | 334 | } | |
| 148 | 335 | return func(valid bool) { | |
| 149 | - | defer t.finishObservedOperation() | |
| 336 | + | defer t.finishObservedOperation(kind) | |
| 150 | 337 | t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid)) | |
| 151 | 338 | } | |
| 152 | 339 | } | |
| @@ -163,20 +350,25 @@ func (t *transport) mapEOFError(err error) error { | |||
| 163 | 350 | ||
| 164 | 351 | func (t *transport) Close() error { | |
| 165 | 352 | t.closeOnce.Do(func() { | |
| 166 | - | done := t.beginShutdown() | |
| 353 | + | wait := t.beginShutdown() | |
| 167 | 354 | // Close owned streams before waiting for in-flight operations. The | |
| 168 | 355 | // writer is what releases a Send blocked on a child stdin pipe; the | |
| 169 | - | // reader releases a Recv blocked in io.ReadFull. | |
| 356 | + | // reader releases a Recv blocked in io.ReadFull. Unowned streams cannot | |
| 357 | + | // be interrupted safely, so the cancellation boundary releases Recv | |
| 358 | + | // without closing the source. | |
| 170 | 359 | var writeErr, readErr error | |
| 171 | - | if w, ok := t.w.(io.Closer); ok { | |
| 172 | - | writeErr = w.Close() | |
| 360 | + | if t.writerCloser != nil { | |
| 361 | + | writeErr = t.writerCloser.Close() | |
| 173 | 362 | } | |
| 174 | - | if r, ok := t.r.(io.Closer); ok { | |
| 175 | - | readErr = r.Close() | |
| 363 | + | if t.readerCloser != nil { | |
| 364 | + | readErr = t.readerCloser.Close() | |
| 176 | 365 | } | |
| 177 | 366 | closeErr := t.close() | |
| 178 | - | if done != nil { | |
| 179 | - | <-done | |
| 367 | + | if t.writerCloser != nil && wait.send != nil { | |
| 368 | + | <-wait.send | |
| 369 | + | } | |
| 370 | + | if t.readerCloser != nil && wait.receive != nil { | |
| 371 | + | <-wait.receive | |
| 180 | 372 | } | |
| 181 | 373 | switch { | |
| 182 | 374 | case closeErr != nil: | |
| @@ -190,36 +382,41 @@ func (t *transport) Close() error { | |||
| 190 | 382 | return t.closeErr | |
| 191 | 383 | } | |
| 192 | 384 | ||
| 193 | - | func (t *transport) beginObservedOperation() bool { | |
| 385 | + | func (t *transport) beginObservedOperation(kind operationKind) bool { | |
| 194 | 386 | t.operationMu.Lock() | |
| 195 | 387 | defer t.operationMu.Unlock() | |
| 196 | 388 | if t.closing { | |
| 197 | 389 | return false | |
| 198 | 390 | } | |
| 199 | - | if t.operationCount == 0 { | |
| 200 | - | t.operationsDone = make(chan struct{}) | |
| 391 | + | if t.operationCount[kind] == 0 { | |
| 392 | + | t.operationsDone[kind] = make(chan struct{}) | |
| 201 | 393 | } | |
| 202 | - | t.operationCount++ | |
| 394 | + | t.operationCount[kind]++ | |
| 203 | 395 | return true | |
| 204 | 396 | } | |
| 205 | 397 | ||
| 206 | - | func (t *transport) finishObservedOperation() { | |
| 398 | + | func (t *transport) finishObservedOperation(kind operationKind) { | |
| 207 | 399 | t.operationMu.Lock() | |
| 208 | 400 | defer t.operationMu.Unlock() | |
| 209 | - | t.operationCount-- | |
| 210 | - | if t.operationCount == 0 { | |
| 211 | - | close(t.operationsDone) | |
| 401 | + | t.operationCount[kind]-- | |
| 402 | + | if t.operationCount[kind] == 0 { | |
| 403 | + | close(t.operationsDone[kind]) | |
| 212 | 404 | } | |
| 213 | 405 | } | |
| 214 | 406 | ||
| 215 | - | func (t *transport) beginShutdown() <-chan struct{} { | |
| 407 | + | func (t *transport) beginShutdown() operationWait { | |
| 216 | 408 | t.operationMu.Lock() | |
| 217 | 409 | defer t.operationMu.Unlock() | |
| 218 | 410 | t.closing = true | |
| 219 | - | if t.operationCount == 0 { | |
| 220 | - | return nil | |
| 411 | + | close(t.done) | |
| 412 | + | var wait operationWait | |
| 413 | + | if t.operationCount[operationSend] != 0 { | |
| 414 | + | wait.send = t.operationsDone[operationSend] | |
| 415 | + | } | |
| 416 | + | if t.operationCount[operationReceive] != 0 { | |
| 417 | + | wait.receive = t.operationsDone[operationReceive] | |
| 221 | 418 | } | |
| 222 | - | return t.operationsDone | |
| 419 | + | return wait | |
| 223 | 420 | } | |
| 224 | 421 | ||
| 225 | 422 | type processWaiter struct { | |
| @@ -344,10 +541,11 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string, | |||
| 344 | 541 | return dialContext(ctx, target, session, logger, opts...) | |
| 345 | 542 | } | |
| 346 | 543 | ||
| 347 | - | // DialContext is like Dial, but the context is propagated to ssh startup so a | |
| 348 | - | // canceled attach attempt interrupts the local ssh process. Callers may pass a | |
| 349 | - | // logger to record ssh start failures and non-clean exits without logging the | |
| 350 | - | // generated command line. | |
| 544 | + | // DialContext is like Dial, but the context gates ssh startup. Once the | |
| 545 | + | // transport is returned, its Close method owns the subprocess lifetime; the | |
| 546 | + | // handshake context must not kill an already-established carriage. Callers may | |
| 547 | + | // pass a logger to record ssh start failures and non-clean exits without logging | |
| 548 | + | // the generated command line. | |
| 351 | 549 | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) { | |
| 352 | 550 | var log *slog.Logger | |
| 353 | 551 | if len(logger) > 0 { | |
| @@ -361,7 +559,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||
| 361 | 559 | return nil, err | |
| 362 | 560 | } | |
| 363 | 561 | spec := BuildCommand(target, session) | |
| 364 | - | cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) | |
| 562 | + | // The caller's context is also the bounded protocol-handshake context and is | |
| 563 | + | // canceled after the first committed publication. Binding it to the command | |
| 564 | + | // would kill a healthy long-lived carriage at that boundary. Transport.Close | |
| 565 | + | // owns cancellation after Start; the preflight check above keeps canceled | |
| 566 | + | // attempts from starting a process. | |
| 567 | + | cmd := exec.Command(spec.Path, spec.Args...) | |
| 365 | 568 | stdin, err := cmd.StdinPipe() | |
| 366 | 569 | if err != nil { | |
| 367 | 570 | return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err) | |
| @@ -381,6 +584,10 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||
| 381 | 584 | ||
| 382 | 585 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 383 | 586 | transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr) | |
| 587 | + | if err := ctx.Err(); err != nil { | |
| 588 | + | _ = transport.Close() | |
| 589 | + | return nil, err | |
| 590 | + | } | |
| 384 | 591 | for _, opt := range opts { | |
| 385 | 592 | if opt != nil { | |
| 386 | 593 | opt(transport) | |
M internal/adapters/sshstdio/transport_observability_test.go +78 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "sync" | |
| @@ -93,7 +94,7 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) { | |||
| 93 | 94 | Kind string `json:"kind"` | |
| 94 | 95 | Valid bool `json:"valid"` | |
| 95 | 96 | } | |
| 96 | - | for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { | |
| 97 | + | for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") { | |
| 97 | 98 | var mark struct { | |
| 98 | 99 | ProcessID string `json:"process_id"` | |
| 99 | 100 | Scenario string `json:"scenario"` | |
| @@ -126,6 +127,82 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) { | |||
| 126 | 127 | } | |
| 127 | 128 | } | |
| 128 | 129 | ||
| 130 | + | func TestProcessStdinPumpIsSingleton(t *testing.T) { | |
| 131 | + | first := processStdinPumpFor(os.Stdin) | |
| 132 | + | second := processStdinPumpFor(os.Stdin) | |
| 133 | + | if first != second { | |
| 134 | + | t.Fatal("process stdin created more than one lifetime pump") | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestTransportCloseLeavesProcessStdinOpen(t *testing.T) { | |
| 139 | + | transport := NewTransport(os.Stdin, io.Discard, nil) | |
| 140 | + | if err := transport.Close(); err != nil { | |
| 141 | + | t.Fatalf("Close() error = %v", err) | |
| 142 | + | } | |
| 143 | + | if _, err := os.Stdin.Stat(); err != nil { | |
| 144 | + | t.Fatalf("process stdin was closed: %v", err) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestTransportObservabilitySSHStdioCloseDoesNotWaitForUnownedReceive(t *testing.T) { | |
| 149 | + | tracePath := t.TempDir() + "/ssh-unowned-close.jsonl" | |
| 150 | + | observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 313)}, "ssh-process") | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatalf("NewJSONL() error = %v", err) | |
| 153 | + | } | |
| 154 | + | reporter := ports.NewSerializedRuntimeObserver(observer, 64) | |
| 155 | + | defer reporter.Close() | |
| 156 | + | reader := &sshUnownedBlockedReader{entered: make(chan struct{}), release: make(chan struct{})} | |
| 157 | + | t.Cleanup(reader.unblock) | |
| 158 | + | transport := NewTransport(reader, &bytes.Buffer{}, nil, WithRuntimeObserver(reporter)) | |
| 159 | + | recvDone := make(chan error, 1) | |
| 160 | + | go func() { _, err := transport.Recv(); recvDone <- err }() | |
| 161 | + | <-reader.entered | |
| 162 | + | ||
| 163 | + | closeDone := make(chan error, 1) | |
| 164 | + | go func() { closeDone <- transport.Close() }() | |
| 165 | + | select { | |
| 166 | + | case err := <-closeDone: | |
| 167 | + | if err != nil { | |
| 168 | + | t.Fatalf("Close() error = %v", err) | |
| 169 | + | } | |
| 170 | + | case <-time.After(time.Second): | |
| 171 | + | t.Fatal("Close() waited for an unowned blocked reader") | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | select { | |
| 175 | + | case err := <-recvDone: | |
| 176 | + | if err == nil { | |
| 177 | + | t.Fatal("Recv() error = nil after Close()") | |
| 178 | + | } | |
| 179 | + | case <-time.After(time.Second): | |
| 180 | + | t.Fatal("Recv() did not finish after Close()") | |
| 181 | + | } | |
| 182 | + | reporter.Close() | |
| 183 | + | if err := closer.Close(); err != nil { | |
| 184 | + | t.Fatalf("trace Close() error = %v", err) | |
| 185 | + | } | |
| 186 | + | assertSSHReceiveFailurePair(t, tracePath) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | type sshUnownedBlockedReader struct { | |
| 190 | + | entered chan struct{} | |
| 191 | + | release chan struct{} | |
| 192 | + | enteredOnce sync.Once | |
| 193 | + | releaseOnce sync.Once | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (r *sshUnownedBlockedReader) Read([]byte) (int, error) { | |
| 197 | + | r.enteredOnce.Do(func() { close(r.entered) }) | |
| 198 | + | <-r.release | |
| 199 | + | return 0, errors.New("reader released") | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (r *sshUnownedBlockedReader) unblock() { | |
| 203 | + | r.releaseOnce.Do(func() { close(r.release) }) | |
| 204 | + | } | |
| 205 | + | ||
| 129 | 206 | func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) { | |
| 130 | 207 | tracePath := t.TempDir() + "/ssh.jsonl" | |
| 131 | 208 | observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process") | |
A internal/app/final_acceptance_test.go +177 -0
| @@ -0,0 +1,177 @@ | |||
| 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 awaitAcceptanceFrame(t *testing.T, p *pump, typ ports.MsgType, predicate func(ports.Frame) bool) ports.Frame { | |
| 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 frame") | |
| 28 | + | if frame.Type == typ && predicate(frame) { | |
| 29 | + | return frame | |
| 30 | + | } | |
| 31 | + | case <-deadline.C: | |
| 32 | + | t.Fatal("timed out waiting for acceptance frame") | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult { | |
| 38 | + | t.Helper() | |
| 39 | + | frame := awaitAcceptanceFrame(t, p, ports.MsgCommandResult, func(frame ports.Frame) bool { | |
| 40 | + | result, err := ports.UnmarshalCommandResult(frame.Payload) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | return result.RequestID == requestID | |
| 43 | + | }) | |
| 44 | + | result, err := ports.UnmarshalCommandResult(frame.Payload) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | return result | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output { | |
| 50 | + | t.Helper() | |
| 51 | + | frame := awaitAcceptanceFrame(t, p, ports.MsgOutput, func(frame ports.Frame) bool { | |
| 52 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | return predicate(output) | |
| 55 | + | }) | |
| 56 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | return output | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult { | |
| 62 | + | t.Helper() | |
| 63 | + | payload, err := ports.MarshalCommandRequest(ports.CommandRequest{ | |
| 64 | + | Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug, | |
| 65 | + | }) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 68 | + | return awaitAcceptanceCommand(t, p, requestID) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) { | |
| 72 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 73 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil)) | |
| 74 | + | first, firstPump := attach(t, dir, ports.IntentNew, "shared", size) | |
| 75 | + | second, secondPump := attach(t, dir, ports.IntentAttach, "shared", size) | |
| 76 | + | t.Cleanup(func() { _ = first.Close() }) | |
| 77 | + | t.Cleanup(func() { _ = second.Close() }) | |
| 78 | + | ||
| 79 | + | sendAcceptanceInput(t, first, "SHARED_INPUT\n") | |
| 80 | + | awaitText(t, firstPump, size, "SHARED_INPUT") | |
| 81 | + | awaitText(t, secondPump, size, "SHARED_INPUT") | |
| 82 | + | ||
| 83 | + | // A tab opened by the first attachment is shared session topology, but its | |
| 84 | + | // selected window is not. The second attachment stays on the original tab. | |
| 85 | + | sendAcceptanceInput(t, first, "\x1b ") | |
| 86 | + | awaitText(t, firstPump, size, "Commands") | |
| 87 | + | sendAcceptanceInput(t, first, "CNT\r") | |
| 88 | + | awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ") | |
| 89 | + | assertNoTextAfterInput(t, secondPump, size, " 1 (cat) 2 (cat) ") | |
| 90 | + | ||
| 91 | + | sendAcceptanceInput(t, first, "\x1b ") | |
| 92 | + | awaitText(t, firstPump, size, "Commands") | |
| 93 | + | sendAcceptanceInput(t, first, "SPR\r") | |
| 94 | + | sendAcceptanceInput(t, first, "FIRST_PANE_INPUT\n") | |
| 95 | + | awaitText(t, firstPump, size, "FIRST_PANE_INPUT") | |
| 96 | + | assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT") | |
| 97 | + | sendAcceptanceInput(t, second, "SECOND_TAB_INPUT\n") | |
| 98 | + | awaitText(t, secondPump, size, "SECOND_TAB_INPUT") | |
| 99 | + | ||
| 100 | + | // Overlays are attachment-owned: a palette on one wire and copy mode on | |
| 101 | + | // the other must not repaint the peer's viewport. | |
| 102 | + | sendAcceptanceInput(t, first, "\x1b ") | |
| 103 | + | awaitText(t, firstPump, size, "Commands") | |
| 104 | + | assertNoTextAfterInput(t, secondPump, size, "Commands") | |
| 105 | + | sendAcceptanceInput(t, first, "\x1b") | |
| 106 | + | ||
| 107 | + | sendAcceptanceInput(t, second, "\x1b ") | |
| 108 | + | awaitText(t, secondPump, size, "Commands") | |
| 109 | + | sendAcceptanceInput(t, second, "VIS\r") | |
| 110 | + | awaitText(t, secondPump, size, "[SCROLL]") | |
| 111 | + | assertNoTextAfterInput(t, firstPump, size, "[SCROLL]") | |
| 112 | + | ||
| 113 | + | // A resize and an output reset are scoped to one attachment output stream; | |
| 114 | + | // the shared tab's PTY/content geometry and the peer stream remain intact. | |
| 115 | + | resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}}) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload})) | |
| 118 | + | resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) }) | |
| 119 | + | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size) | |
| 120 | + | assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT") | |
| 121 | + | ||
| 122 | + | require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})})) | |
| 123 | + | reset := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Base == 0 && output.Full }) | |
| 124 | + | require.True(t, reset.Full) | |
| 125 | + | assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT") | |
| 126 | + | ||
| 127 | + | require.NoError(t, first.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})})) | |
| 128 | + | deadline := time.NewTimer(5 * time.Second) | |
| 129 | + | defer deadline.Stop() | |
| 130 | + | for { | |
| 131 | + | select { | |
| 132 | + | case frame := <-firstPump.ch: | |
| 133 | + | if frame.Type == ports.MsgDetached { | |
| 134 | + | goto detached | |
| 135 | + | } | |
| 136 | + | case <-deadline.C: | |
| 137 | + | t.Fatal("timed out waiting for first attachment detach") | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | detached: | |
| 142 | + | sendAcceptanceInput(t, second, "q") | |
| 143 | + | sendAcceptanceInput(t, second, "PEER_SURVIVES\n") | |
| 144 | + | awaitText(t, secondPump, size, "PEER_SURVIVES") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) { | |
| 148 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 149 | + | dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil)) | |
| 150 | + | first, firstPump := attach(t, dir, ports.IntentNew, "commands", size) | |
| 151 | + | second, secondPump := attach(t, dir, ports.IntentAttach, "commands", size) | |
| 152 | + | t.Cleanup(func() { _ = first.Close() }) | |
| 153 | + | t.Cleanup(func() { _ = second.Close() }) | |
| 154 | + | ||
| 155 | + | firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab") | |
| 156 | + | secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab") | |
| 157 | + | require.True(t, firstResult.OK, firstResult.Text) | |
| 158 | + | require.True(t, secondResult.OK, secondResult.Text) | |
| 159 | + | require.Empty(t, firstResult.Output) | |
| 160 | + | require.Empty(t, secondResult.Output) | |
| 161 | + | require.NotEqual(t, firstResult.RequestID, secondResult.RequestID) | |
| 162 | + | ||
| 163 | + | // Drain any asynchronous output before proving that a reset on one real | |
| 164 | + | // IPC transport does not create a reset frame on the other. | |
| 165 | + | for { | |
| 166 | + | select { | |
| 167 | + | case <-secondPump.ch: | |
| 168 | + | default: | |
| 169 | + | goto drained | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | drained: | |
| 174 | + | require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})})) | |
| 175 | + | awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Full && output.Base == 0 }) | |
| 176 | + | assertNoTextAfterInput(t, secondPump, size, "Commands") | |
| 177 | + | } | |
A internal/app/remote_final_acceptance_test.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 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 | + | payload, err := ports.MarshalOutput(ports.Output{ | |
| 81 | + | Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data, | |
| 82 | + | }) | |
| 83 | + | if err != nil { | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | _ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}) | |
| 90 | + | for { | |
| 91 | + | if _, err := tr.Recv(); err != nil { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | type acceptanceRemoteTerminal struct { | |
| 99 | + | in io.Reader | |
| 100 | + | out bytes.Buffer | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) { | |
| 104 | + | return func() error { return nil }, nil | |
| 105 | + | } | |
| 106 | + | func (*acceptanceRemoteTerminal) Size() (domain.Size, error) { | |
| 107 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |
| 108 | + | } | |
| 109 | + | func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil } | |
| 110 | + | func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in } | |
| 111 | + | func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out } | |
| 112 | + | func (*acceptanceRemoteTerminal) Flush() error { return nil } | |
| 113 | + | ||
| 114 | + | func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) { | |
| 115 | + | outputs := make(chan string, 4) | |
| 116 | + | factory := &acceptanceRemoteFactory{outputs: outputs} | |
| 117 | + | localCalls := 0 | |
| 118 | + | terminals := make([]*acceptanceRemoteTerminal, 0, 3) | |
| 119 | + | deps := runAttachDeps{ | |
| 120 | + | localDialer: func() ports.Dialer { | |
| 121 | + | localCalls++ | |
| 122 | + | return acceptanceRemoteDialer{} | |
| 123 | + | }, | |
| 124 | + | remoteDialerFactory: factory, | |
| 125 | + | selectedRemoteTransport: string(ports.RemoteTransportStdio), | |
| 126 | + | runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 127 | + | term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)} | |
| 128 | + | terminals = append(terminals, term) | |
| 129 | + | deps.Terminal = term | |
| 130 | + | deps.Logger = slog.New(slog.DiscardHandler) | |
| 131 | + | deps.Remote = true | |
| 132 | + | result := client.NewRunner(deps).Run(ctx, request) | |
| 133 | + | if request.SessionName == "work" { | |
| 134 | + | require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff") | |
| 135 | + | return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}} | |
| 136 | + | } | |
| 137 | + | return result | |
| 138 | + | }, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | wireTransport, err := (acceptanceRemoteDialer{ | |
| 142 | + | target: "picker-wire.example", | |
| 143 | + | handoff: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}, | |
| 144 | + | }).Dial(context.Background()) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{ | |
| 147 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, | |
| 148 | + | })})) | |
| 149 | + | welcome, err := wireTransport.Recv() | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 152 | + | require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{})})) | |
| 153 | + | attachTarget, err := wireTransport.Recv() | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | target, err := ports.UnmarshalAttachTarget(attachTarget.Payload) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | require.Equal(t, ports.MsgAttachTarget, attachTarget.Type) | |
| 158 | + | require.Equal(t, "picked.example", target.Endpoint) | |
| 159 | + | require.NoError(t, wireTransport.Close()) | |
| 160 | + | ||
| 161 | + | require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "direct", "direct.example", "", nil, deps)) | |
| 162 | + | require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "picker.example", "", nil, deps)) | |
| 163 | + | factory.mu.Lock() | |
| 164 | + | calls := append([]string(nil), factory.calls...) | |
| 165 | + | factory.mu.Unlock() | |
| 166 | + | require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls) | |
| 167 | + | require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow") | |
| 168 | + | require.Len(t, terminals, 3) | |
| 169 | + | require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs}) | |
| 170 | + | } | |
M internal/app/run.go +3 -2
| @@ -1040,8 +1040,9 @@ func runStdio(ctx context.Context) (retErr error) { | |||
| 1040 | 1040 | ||
| 1041 | 1041 | // sshstdio.Close owns and closes writers that implement io.Closer so a | |
| 1042 | 1042 | // blocked Send can unwind. Keep process-global stdin/stdout outside that | |
| 1043 | - | // ownership boundary: input is a non-closing view and output is an owned | |
| 1044 | - | // pipe drained by a small forwarding goroutine. | |
| 1043 | + | // ownership boundary: sshstdio recognizes process stdin as a shared | |
| 1044 | + | // cancellable pump, while output is an owned pipe drained by a forwarding | |
| 1045 | + | // goroutine. | |
| 1045 | 1046 | stdin, stdout := os.Stdin, os.Stdout | |
| 1046 | 1047 | stdoutReader, stdoutWriter := io.Pipe() | |
| 1047 | 1048 | forwardDone := make(chan struct{}) | |
M internal/ports/wire.go +27 -11
| @@ -26,6 +26,7 @@ var errShortPayload = errors.New("ports: payload too short") | |||
| 26 | 26 | var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | |
| 27 | 27 | ||
| 28 | 28 | var errInvalidBoolean = errors.New("ports: invalid boolean flag") | |
| 29 | + | var errInvalidEnum = errors.New("ports: invalid enum value") | |
| 29 | 30 | ||
| 30 | 31 | const ( | |
| 31 | 32 | outputHeaderLen = 5*8 + 2*2 + 1 | |
| @@ -170,6 +171,15 @@ func validClientNoticeAction(action uint8) bool { | |||
| 170 | 171 | } | |
| 171 | 172 | } | |
| 172 | 173 | ||
| 174 | + | func validDetachedReason(reason uint8) bool { | |
| 175 | + | switch reason { | |
| 176 | + | case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced: | |
| 177 | + | return true | |
| 178 | + | default: | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 173 | 183 | // Detach asks the daemon to detach the current client without killing the | |
| 174 | 184 | // session. | |
| 175 | 185 | type Detach struct{} | |
| @@ -214,7 +224,7 @@ type Output struct { | |||
| 214 | 224 | } | |
| 215 | 225 | ||
| 216 | 226 | // AttachTarget identifies the endpoint and session selected for a new | |
| 217 | - | // attachment. It deliberately carries no viewport, resume, or proxy state. | |
| 227 | + | // attachment. It deliberately carries no viewport, resume, or session state. | |
| 218 | 228 | type AttachTarget struct { | |
| 219 | 229 | Endpoint string | |
| 220 | 230 | Session string | |
| @@ -817,6 +827,9 @@ func UnmarshalTheme(b []byte) (Theme, error) { | |||
| 817 | 827 | if err != nil { | |
| 818 | 828 | return Theme{}, err | |
| 819 | 829 | } | |
| 830 | + | if flags&^uint8(0x1f) != 0 { | |
| 831 | + | return Theme{}, errInvalidEnum | |
| 832 | + | } | |
| 820 | 833 | fgR, err := r.getUint8() | |
| 821 | 834 | if err != nil { | |
| 822 | 835 | return Theme{}, err | |
| @@ -994,11 +1007,11 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||
| 994 | 1007 | if m.SessionName, err = r.getString(); err != nil { | |
| 995 | 1008 | return Welcome{}, err | |
| 996 | 1009 | } | |
| 997 | - | eph, err := r.getUint8() | |
| 998 | - | if err != nil { | |
| 1010 | + | var ephemeral bool | |
| 1011 | + | if ephemeral, err = r.getBool(); err != nil { | |
| 999 | 1012 | return Welcome{}, err | |
| 1000 | 1013 | } | |
| 1001 | - | m.Ephemeral = eph != 0 | |
| 1014 | + | m.Ephemeral = ephemeral | |
| 1002 | 1015 | if m.ResumeToken, err = r.getUint64(); err != nil { | |
| 1003 | 1016 | return Welcome{}, err | |
| 1004 | 1017 | } | |
| @@ -1185,6 +1198,9 @@ func UnmarshalDetached(b []byte) (Detached, error) { | |||
| 1185 | 1198 | if err != nil { | |
| 1186 | 1199 | return Detached{}, err | |
| 1187 | 1200 | } | |
| 1201 | + | if !validDetachedReason(reason) { | |
| 1202 | + | return Detached{}, errInvalidEnum | |
| 1203 | + | } | |
| 1188 | 1204 | if err := r.done(); err != nil { | |
| 1189 | 1205 | return Detached{}, err | |
| 1190 | 1206 | } | |
| @@ -1312,9 +1328,9 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 1312 | 1328 | if err != nil { | |
| 1313 | 1329 | return Kill{}, err | |
| 1314 | 1330 | } | |
| 1315 | - | var all uint8 | |
| 1331 | + | var all bool | |
| 1316 | 1332 | if len(r.b) > 0 { | |
| 1317 | - | all, err = r.getUint8() | |
| 1333 | + | all, err = r.getBool() | |
| 1318 | 1334 | if err != nil { | |
| 1319 | 1335 | return Kill{}, err | |
| 1320 | 1336 | } | |
| @@ -1322,7 +1338,7 @@ func UnmarshalKill(b []byte) (Kill, error) { | |||
| 1322 | 1338 | if err := r.done(); err != nil { | |
| 1323 | 1339 | return Kill{}, err | |
| 1324 | 1340 | } | |
| 1325 | - | return Kill{Name: name, All: all != 0}, nil | |
| 1341 | + | return Kill{Name: name, All: all}, nil | |
| 1326 | 1342 | } | |
| 1327 | 1343 | ||
| 1328 | 1344 | // MarshalSessions encodes m into a Sessions message payload: a uint16 count | |
| @@ -1365,19 +1381,19 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 1365 | 1381 | if s.Name, err = r.getString(); err != nil { | |
| 1366 | 1382 | return Sessions{}, err | |
| 1367 | 1383 | } | |
| 1368 | - | eph, err := r.getUint8() | |
| 1384 | + | eph, err := r.getBool() | |
| 1369 | 1385 | if err != nil { | |
| 1370 | 1386 | return Sessions{}, err | |
| 1371 | 1387 | } | |
| 1372 | - | s.Ephemeral = eph != 0 | |
| 1388 | + | s.Ephemeral = eph | |
| 1373 | 1389 | if s.Tabs, err = r.getUint16(); err != nil { | |
| 1374 | 1390 | return Sessions{}, err | |
| 1375 | 1391 | } | |
| 1376 | - | att, err := r.getUint8() | |
| 1392 | + | att, err := r.getBool() | |
| 1377 | 1393 | if err != nil { | |
| 1378 | 1394 | return Sessions{}, err | |
| 1379 | 1395 | } | |
| 1380 | - | s.Attached = att != 0 | |
| 1396 | + | s.Attached = att | |
| 1381 | 1397 | state, err := r.getUint8() | |
| 1382 | 1398 | if err != nil { | |
| 1383 | 1399 | return Sessions{}, err | |
M internal/ports/wire_final_test.go +68 -0
| @@ -195,3 +195,71 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 195 | 195 | } | |
| 196 | 196 | } | |
| 197 | 197 | } | |
| 198 | + | ||
| 199 | + | func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |
| 200 | + | meta, err := MarshalSessionMeta(SessionMeta{Tabs: []SessionTabMeta{{Name: "tab"}}}) | |
| 201 | + | if err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | tests := []struct { | |
| 205 | + | name string | |
| 206 | + | payload []byte | |
| 207 | + | decode func([]byte) error | |
| 208 | + | }{ | |
| 209 | + | { | |
| 210 | + | name: "hello boolean", | |
| 211 | + | payload: func() []byte { | |
| 212 | + | b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | |
| 213 | + | b[len(b)-6] = 2 | |
| 214 | + | return b | |
| 215 | + | }(), | |
| 216 | + | decode: func(b []byte) error { _, err := UnmarshalHello(b); return err }, | |
| 217 | + | }, | |
| 218 | + | { | |
| 219 | + | name: "welcome boolean", | |
| 220 | + | payload: func() []byte { | |
| 221 | + | b := MarshalWelcome(Welcome{SessionID: "id"}) | |
| 222 | + | b[6] = 2 | |
| 223 | + | return b | |
| 224 | + | }(), | |
| 225 | + | decode: func(b []byte) error { _, err := UnmarshalWelcome(b); return err }, | |
| 226 | + | }, | |
| 227 | + | {name: "detached reason", payload: []byte{0xff}, decode: func(b []byte) error { _, err := UnmarshalDetached(b); return err }}, | |
| 228 | + | { | |
| 229 | + | name: "kill boolean", | |
| 230 | + | payload: func() []byte { | |
| 231 | + | b := MarshalKill(Kill{All: true}) | |
| 232 | + | b[len(b)-1] = 2 | |
| 233 | + | return b | |
| 234 | + | }(), | |
| 235 | + | decode: func(b []byte) error { _, err := UnmarshalKill(b); return err }, | |
| 236 | + | }, | |
| 237 | + | { | |
| 238 | + | name: "sessions ephemeral boolean", | |
| 239 | + | payload: func() []byte { | |
| 240 | + | b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}}) | |
| 241 | + | b[6] = 2 | |
| 242 | + | return b | |
| 243 | + | }(), | |
| 244 | + | decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err }, | |
| 245 | + | }, | |
| 246 | + | { | |
| 247 | + | name: "sessions attached boolean", | |
| 248 | + | payload: func() []byte { | |
| 249 | + | b := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: SessionRunning}}}) | |
| 250 | + | b[9] = 2 | |
| 251 | + | return b | |
| 252 | + | }(), | |
| 253 | + | decode: func(b []byte) error { _, err := UnmarshalSessions(b); return err }, | |
| 254 | + | }, | |
| 255 | + | {name: "session metadata boolean", payload: func() []byte { b := append([]byte(nil), meta...); b[len(b)-1] = 2; return b }(), decode: func(b []byte) error { _, err := UnmarshalSessionMeta(b); return err }}, | |
| 256 | + | {name: "theme flags", payload: func() []byte { b := make([]byte, 57); b[0] = 0x20; return b }(), decode: func(b []byte) error { _, err := UnmarshalTheme(b); return err }}, | |
| 257 | + | } | |
| 258 | + | for _, tt := range tests { | |
| 259 | + | t.Run(tt.name, func(t *testing.T) { | |
| 260 | + | if err := tt.decode(tt.payload); err == nil { | |
| 261 | + | t.Fatal("decoder accepted unknown closed wire value") | |
| 262 | + | } | |
| 263 | + | }) | |
| 264 | + | } | |
| 265 | + | } | |
M internal/usecase/daemon/attachment_connection.go +5 -14
| @@ -2,21 +2,12 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/vev/internal/ports" | |
| 4 | 4 | ||
| 5 | - | // attachmentSessionCore normalizes nil interfaces and implementations that | |
| 6 | - | // deliberately expose no attachment core. | |
| 7 | - | func attachmentSessionCore(entry attachmentSession) *sessionCore { | |
| 8 | - | if entry == nil { | |
| 9 | - | return nil | |
| 10 | - | } | |
| 11 | - | return entry.core() | |
| 12 | - | } | |
| 13 | - | ||
| 14 | 5 | // attachmentConnectionToken is the exact capability for one registered | |
| 15 | 6 | // attachment connection incarnation. It is invalid as soon as any of the | |
| 16 | 7 | // session, attachment membership, connection generation, transport link, or | |
| 17 | 8 | // coordinator lease changes. | |
| 18 | 9 | type attachmentConnectionToken struct { | |
| 19 | - | sess attachmentSession | |
| 10 | + | sess *session | |
| 20 | 11 | ac *attachedClient | |
| 21 | 12 | generation uint64 | |
| 22 | 13 | transport transportSnapshot | |
| @@ -27,7 +18,7 @@ type attachmentConnectionToken struct { | |||
| 27 | 18 | rebase bool | |
| 28 | 19 | } | |
| 29 | 20 | ||
| 30 | - | func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken { | |
| 21 | + | func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken { | |
| 31 | 22 | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |
| 32 | 23 | return attachmentConnectionToken{} | |
| 33 | 24 | } | |
| @@ -70,7 +61,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach | |||
| 70 | 61 | } | |
| 71 | 62 | ||
| 72 | 63 | // current validates every immutable identity captured by the token. Membership | |
| 73 | - | // is the sole authority; there is no owner, replacement, or compatibility distinction. | |
| 64 | + | // is the sole authority; there is no owner, replacement, or compatibility role. | |
| 74 | 65 | func (t attachmentConnectionToken) current() bool { | |
| 75 | 66 | return t.sess != nil && t.ac != nil && | |
| 76 | 67 | t.ac.connectionGeneration.Load() == t.generation && | |
| @@ -100,8 +91,8 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool { | |||
| 100 | 91 | return t.attachmentCurrent() | |
| 101 | 92 | } | |
| 102 | 93 | ||
| 103 | - | func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) { | |
| 104 | - | if sess == nil || ac == nil || lease == nil || lease.attachment != ac { | |
| 94 | + | func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) { | |
| 95 | + | if sess == nil || ac == nil || lease == nil { | |
| 105 | 96 | return nil, false | |
| 106 | 97 | } | |
| 107 | 98 | token := attachmentToken(sess, ac, ac.transport()) | |
M internal/usecase/daemon/attachment_effect_gate.go +1 -1
| @@ -18,7 +18,7 @@ const ( | |||
| 18 | 18 | // effect admission. It deliberately contains no independently mutable state: | |
| 19 | 19 | // a ticket is admitted only when every field matches the frame's token. | |
| 20 | 20 | type attachmentCapability struct { | |
| 21 | - | sess attachmentSession | |
| 21 | + | sess *session | |
| 22 | 22 | generation uint64 | |
| 23 | 23 | transport transportSnapshot | |
| 24 | 24 | lease *attachmentLease | |
M internal/usecase/daemon/attachment_effect_gate_test.go +18 -18
| @@ -165,7 +165,7 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te | |||
| 165 | 165 | ||
| 166 | 166 | func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) { | |
| 167 | 167 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 168 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 168 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 169 | 169 | foreign := &attachmentLease{attachment: &attachedClient{}} | |
| 170 | 170 | _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign) | |
| 171 | 171 | require.False(t, admitted) | |
| @@ -196,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 196 | 196 | sess.registerAttachmentLocked(later) | |
| 197 | 197 | sess.mu.Unlock() | |
| 198 | 198 | ||
| 199 | - | rc := d.attachCoordinator(sess, first, true) | |
| 199 | + | rc := d.attachCoordinator(sess, nil, first, true) | |
| 200 | 200 | firstToken := sess.attachmentToken(first, firstTransport) | |
| 201 | 201 | firstToken.lease = rc.attachmentLease(first) | |
| 202 | 202 | first.publishAttachmentCapability(firstToken) | |
| @@ -302,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 302 | 302 | d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 303 | 303 | snatchedTransport := newTeardownBlockingTransport() | |
| 304 | 304 | snatched.replaceTransport(snatchedTransport) | |
| 305 | - | d.attachCoordinator(sess, snatched, true) | |
| 305 | + | d.attachCoordinator(sess, nil, snatched, true) | |
| 306 | 306 | snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 307 | 307 | ||
| 308 | 308 | activeTransport := newTeardownBlockingTransport() | |
| @@ -322,7 +322,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 322 | 322 | d.mu.Lock() | |
| 323 | 323 | d.sessions[unrelated.id] = unrelated | |
| 324 | 324 | d.mu.Unlock() | |
| 325 | - | d.attachCoordinator(unrelated, unrelatedClient, true) | |
| 325 | + | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 326 | 326 | ||
| 327 | 327 | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 328 | 328 | activeToken := sess.attachmentToken(active, activeTransport) | |
| @@ -358,7 +358,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 358 | 358 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 359 | 359 | stale := newTeardownBlockingTransport() | |
| 360 | 360 | ac.replaceTransport(stale) | |
| 361 | - | d.attachCoordinator(sess, ac, true) | |
| 361 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 362 | 362 | ac.publishAttachmentCapability(sess.attachmentToken(ac, stale)) | |
| 363 | 363 | ||
| 364 | 364 | token := sess.attachmentToken(ac, stale) | |
| @@ -387,7 +387,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T | |||
| 387 | 387 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 388 | 388 | transport := &closeTrackingTransport{} | |
| 389 | 389 | active.replaceTransport(transport) | |
| 390 | - | d.attachCoordinator(sess, active, true) | |
| 390 | + | d.attachCoordinator(sess, nil, active, true) | |
| 391 | 391 | active.publishAttachmentCapability(sess.attachmentToken(active, transport)) | |
| 392 | 392 | ||
| 393 | 393 | owner := freezeAttachmentEffectGates(active) | |
| @@ -418,7 +418,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |||
| 418 | 418 | d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 419 | 419 | blocked := newTeardownBlockingTransport() | |
| 420 | 420 | active.replaceTransport(blocked) | |
| 421 | - | d.attachCoordinator(sess, active, true) | |
| 421 | + | d.attachCoordinator(sess, nil, active, true) | |
| 422 | 422 | active.publishAttachmentCapability(sess.attachmentToken(active, blocked)) | |
| 423 | 423 | ||
| 424 | 424 | token := sess.attachmentToken(active, blocked) | |
| @@ -444,7 +444,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus | |||
| 444 | 444 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 445 | 445 | transport := &closeTrackingTransport{} | |
| 446 | 446 | ac.replaceTransport(transport) | |
| 447 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 447 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 448 | 448 | token := sess.attachmentToken(ac, transport) | |
| 449 | 449 | token.lease = rc.attachmentLease(ac) | |
| 450 | 450 | ac.publishAttachmentCapability(token) | |
| @@ -474,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||
| 474 | 474 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 475 | 475 | oldTransport := newBlockedRenderReplacementTransport(d, sess) | |
| 476 | 476 | old.replaceTransport(oldTransport) | |
| 477 | - | rc := d.attachCoordinator(sess, old, true) | |
| 477 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 478 | 478 | lease := rc.attachmentLease(old) | |
| 479 | 479 | token := sess.attachmentToken(old, oldTransport) | |
| 480 | 480 | token.lease = lease | |
| @@ -529,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition( | |||
| 529 | 529 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 530 | 530 | oldTransport := newDatagramTestTransport() | |
| 531 | 531 | old.replaceTransport(oldTransport) | |
| 532 | - | rc := d.attachCoordinator(sess, old, true) | |
| 532 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 533 | 533 | token := sess.attachmentToken(old, oldTransport) | |
| 534 | 534 | token.lease = rc.attachmentLease(old) | |
| 535 | 535 | old.publishAttachmentCapability(token) | |
| @@ -630,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes | |||
| 630 | 630 | oldTransport := newDatagramTestTransport() | |
| 631 | 631 | old.replaceTransport(oldTransport) | |
| 632 | 632 | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 633 | - | rc := d.attachCoordinator(sess, old, true) | |
| 633 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 634 | 634 | token := sess.attachmentToken(old, oldTransport) | |
| 635 | 635 | token.lease = rc.attachmentLease(old) | |
| 636 | 636 | if tt.setup != nil { | |
| @@ -683,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 683 | 683 | target.tabs[1].attentionAt = time.Unix(1, 0) | |
| 684 | 684 | d.sessions[target.id] = target | |
| 685 | 685 | ||
| 686 | - | rc := d.attachCoordinator(source, ac, true) | |
| 686 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 687 | 687 | token := source.attachmentToken(ac, ac.transport()) | |
| 688 | 688 | token.lease = rc.attachmentLease(ac) | |
| 689 | 689 | ac.publishAttachmentCapability(token) | |
| @@ -701,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 701 | 701 | d.enterPicker(sess, old) | |
| 702 | 702 | ||
| 703 | 703 | oldTransport := old.transport() | |
| 704 | - | rc := d.attachCoordinator(sess, old, true) | |
| 704 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 705 | 705 | token := sess.attachmentToken(old, oldTransport) | |
| 706 | 706 | token.lease = rc.attachmentLease(old) | |
| 707 | 707 | old.publishAttachmentCapability(token) | |
| @@ -745,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 745 | 745 | defer release() | |
| 746 | 746 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 747 | 747 | d.enterPicker(sess, ac) | |
| 748 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 748 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 749 | 749 | token := sess.attachmentToken(ac, ac.transport()) | |
| 750 | 750 | token.lease = rc.attachmentLease(ac) | |
| 751 | 751 | ac.publishAttachmentCapability(token) | |
| @@ -762,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test | |||
| 762 | 762 | d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 763 | 763 | oldTransport := newDatagramTestTransport() | |
| 764 | 764 | old.replaceTransport(oldTransport) | |
| 765 | - | rc := d.attachCoordinator(sess, old, true) | |
| 765 | + | rc := d.attachCoordinator(sess, nil, old, true) | |
| 766 | 766 | token := sess.attachmentToken(old, oldTransport) | |
| 767 | 767 | token.lease = rc.attachmentLease(old) | |
| 768 | 768 | ||
| @@ -796,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 796 | 796 | d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 797 | 797 | aTransport := newDatagramTestTransport() | |
| 798 | 798 | a.replaceTransport(aTransport) | |
| 799 | - | d.attachCoordinator(first, a, true) | |
| 799 | + | d.attachCoordinator(first, nil, a, true) | |
| 800 | 800 | first.attachmentToken(a, aTransport) | |
| 801 | 801 | ||
| 802 | 802 | bTransport := newDatagramTestTransport() | |
| @@ -807,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 807 | 807 | d.mu.Lock() | |
| 808 | 808 | d.sessions[second.id] = second | |
| 809 | 809 | d.mu.Unlock() | |
| 810 | - | d.attachCoordinator(second, b, true) | |
| 810 | + | d.attachCoordinator(second, nil, b, true) | |
| 811 | 811 | second.attachmentToken(b, bTransport) | |
| 812 | 812 | ||
| 813 | 813 | start := make(chan struct{}) | |
| @@ -854,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) { | |||
| 854 | 854 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 855 | 855 | transport := newDatagramTestTransport() | |
| 856 | 856 | ac.replaceTransport(transport) | |
| 857 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 857 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 858 | 858 | token := sess.attachmentToken(ac, transport) | |
| 859 | 859 | token.lease = rc.attachmentLease(ac) | |
| 860 | 860 | ac.publishAttachmentCapability(token) | |
M internal/usecase/daemon/attachment_registry.go +91 -69
| @@ -23,13 +23,11 @@ type attachmentView struct { | |||
| 23 | 23 | revision uint64 | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | // viewInvalidation records the order in which attachment views observed one | |
| 27 | - | // shared session mutation. The order is stable client-ID order, not map | |
| 28 | - | // iteration or lock-acquisition order, which keeps concurrent attaches | |
| 29 | - | // deterministic. | |
| 30 | - | type viewInvalidation struct { | |
| 26 | + | // attachmentViewSnapshot is the immutable value returned to callers that need | |
| 27 | + | // to inspect attachment-local state without retaining its mutex. | |
| 28 | + | type attachmentViewSnapshot struct { | |
| 31 | 29 | attachment *attachedClient | |
| 32 | - | revision uint64 | |
| 30 | + | view attachmentView | |
| 33 | 31 | } | |
| 34 | 32 | ||
| 35 | 33 | func (ac *attachedClient) viewSnapshot() attachmentView { | |
| @@ -41,11 +39,7 @@ func (ac *attachedClient) viewSnapshot() attachmentView { | |||
| 41 | 39 | return ac.view | |
| 42 | 40 | } | |
| 43 | 41 | ||
| 44 | - | func (ac *attachedClient) publishView(view attachmentView) { | |
| 45 | - | if ac == nil { | |
| 46 | - | return | |
| 47 | - | } | |
| 48 | - | ac.viewMu.Lock() | |
| 42 | + | func (ac *attachedClient) publishViewLocked(view attachmentView) { | |
| 49 | 43 | // Rebase while viewMu is held, before the new revision becomes visible. | |
| 50 | 44 | // Output fences take the same lock, so no side effect can pair the new | |
| 51 | 45 | // ViewRevision with the previous output epoch. Test/headless fixtures may | |
| @@ -60,7 +54,28 @@ func (ac *attachedClient) publishView(view attachmentView) { | |||
| 60 | 54 | } | |
| 61 | 55 | } | |
| 62 | 56 | ac.view = view | |
| 63 | - | ac.viewMu.Unlock() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (ac *attachedClient) publishView(view attachmentView) { | |
| 60 | + | if ac == nil { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | ac.viewMu.Lock() | |
| 64 | + | defer ac.viewMu.Unlock() | |
| 65 | + | ac.publishViewLocked(view) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool { | |
| 69 | + | if ac == nil { | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | ac.viewMu.Lock() | |
| 73 | + | defer ac.viewMu.Unlock() | |
| 74 | + | if ac.view != before { | |
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | ac.publishViewLocked(view) | |
| 78 | + | return true | |
| 64 | 79 | } | |
| 65 | 80 | ||
| 66 | 81 | // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must | |
| @@ -96,7 +111,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |||
| 96 | 111 | return true | |
| 97 | 112 | } | |
| 98 | 113 | ||
| 99 | - | func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 114 | + | func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | |
| 100 | 115 | if entry == nil || entry.core() == nil { | |
| 101 | 116 | return false | |
| 102 | 117 | } | |
| @@ -138,7 +153,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |||
| 138 | 153 | return true | |
| 139 | 154 | } | |
| 140 | 155 | ||
| 141 | - | func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 156 | + | func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | |
| 142 | 157 | if entry == nil || entry.core() == nil { | |
| 143 | 158 | return false | |
| 144 | 159 | } | |
| @@ -156,26 +171,26 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool { | |||
| 156 | 171 | return s.unregisterAttachmentLocked(ac) | |
| 157 | 172 | } | |
| 158 | 173 | ||
| 159 | - | // snapshotAttachmentsLocked returns a stable registration-ordered snapshot. | |
| 160 | - | // Callers hold s.mu; returned attachments may be used after unlocking. | |
| 161 | - | func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient { | |
| 162 | - | if c == nil || len(c.attachments) == 0 { | |
| 174 | + | func (s *session) snapshotAttachmentsLocked() []*attachedClient { | |
| 175 | + | if s == nil || len(s.attachments) == 0 { | |
| 163 | 176 | return nil | |
| 164 | 177 | } | |
| 165 | - | out := make([]*attachedClient, 0, len(c.attachments)) | |
| 166 | - | for ac := range c.attachments { | |
| 178 | + | out := make([]*attachedClient, 0, len(s.attachments)) | |
| 179 | + | for ac := range s.attachments { | |
| 167 | 180 | out = append(out, ac) | |
| 168 | 181 | } | |
| 169 | 182 | slices.SortStableFunc(out, func(a, b *attachedClient) int { | |
| 183 | + | // ClientID is the stable wire identity, so concurrent registration order | |
| 184 | + | // cannot affect snapshots or invalidation order. | |
| 170 | 185 | if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 { | |
| 171 | 186 | return id | |
| 172 | 187 | } | |
| 173 | - | return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b]) | |
| 188 | + | return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b]) | |
| 174 | 189 | }) | |
| 175 | 190 | return out | |
| 176 | 191 | } | |
| 177 | 192 | ||
| 178 | - | func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool { | |
| 193 | + | func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool { | |
| 179 | 194 | if entry == nil || ac == nil || entry.core() == nil { | |
| 180 | 195 | return false | |
| 181 | 196 | } | |
| @@ -183,7 +198,7 @@ func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) boo | |||
| 183 | 198 | return ok | |
| 184 | 199 | } | |
| 185 | 200 | ||
| 186 | - | func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool { | |
| 201 | + | func attachmentRegistered(entry *session, ac *attachedClient) bool { | |
| 187 | 202 | if entry == nil || ac == nil || entry.core() == nil { | |
| 188 | 203 | return false | |
| 189 | 204 | } | |
| @@ -213,16 +228,6 @@ func (s *session) attachmentViewsTabLocked(tb *tab) bool { | |||
| 213 | 228 | return false | |
| 214 | 229 | } | |
| 215 | 230 | ||
| 216 | - | func snapshotAttachmentSession(entry attachmentSession) []*attachedClient { | |
| 217 | - | if entry == nil || entry.core() == nil { | |
| 218 | - | return nil | |
| 219 | - | } | |
| 220 | - | core := entry.core() | |
| 221 | - | core.mu.Lock() | |
| 222 | - | defer core.mu.Unlock() | |
| 223 | - | return core.snapshotAttachmentsLocked() | |
| 224 | - | } | |
| 225 | - | ||
| 226 | 231 | func (s *session) snapshotAttachments() []*attachedClient { | |
| 227 | 232 | if s == nil { | |
| 228 | 233 | return nil | |
| @@ -232,6 +237,18 @@ func (s *session) snapshotAttachments() []*attachedClient { | |||
| 232 | 237 | return s.snapshotAttachmentsLocked() | |
| 233 | 238 | } | |
| 234 | 239 | ||
| 240 | + | func (s *session) snapshotAttachmentViews() []attachmentViewSnapshot { | |
| 241 | + | attachments := s.snapshotAttachments() | |
| 242 | + | if len(attachments) == 0 { | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | views := make([]attachmentViewSnapshot, 0, len(attachments)) | |
| 246 | + | for _, ac := range attachments { | |
| 247 | + | views = append(views, attachmentViewSnapshot{attachment: ac, view: ac.viewSnapshot()}) | |
| 248 | + | } | |
| 249 | + | return views | |
| 250 | + | } | |
| 251 | + | ||
| 235 | 252 | // repairAttachmentViewLocked validates stable targets and chooses the nearest | |
| 236 | 253 | // surviving target when a tab or pane was removed. Caller holds s.mu. | |
| 237 | 254 | func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView { | |
| @@ -279,10 +296,12 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment | |||
| 279 | 296 | } else { | |
| 280 | 297 | view.paneID = "" | |
| 281 | 298 | } | |
| 299 | + | targetTab.mu.Unlock() | |
| 282 | 300 | if view.windowRows <= 0 { | |
| 301 | + | targetTab.mu.Lock() | |
| 283 | 302 | view.windowRows = targetTab.size.Rows | |
| 303 | + | targetTab.mu.Unlock() | |
| 284 | 304 | } | |
| 285 | - | targetTab.mu.Unlock() | |
| 286 | 305 | if view.windowTop < 0 { | |
| 287 | 306 | view.windowTop = 0 | |
| 288 | 307 | } | |
| @@ -301,19 +320,20 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool { | |||
| 301 | 320 | if s == nil || ac == nil { | |
| 302 | 321 | return false | |
| 303 | 322 | } | |
| 323 | + | before := ac.viewSnapshot() | |
| 304 | 324 | s.mu.Lock() | |
| 305 | - | defer s.mu.Unlock() | |
| 306 | 325 | if _, ok := s.attachments[ac]; !ok { | |
| 326 | + | s.mu.Unlock() | |
| 307 | 327 | return false | |
| 308 | 328 | } | |
| 309 | - | before := ac.viewSnapshot() | |
| 310 | 329 | after := s.repairAttachmentViewLocked(ac, before) | |
| 311 | 330 | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { | |
| 331 | + | s.mu.Unlock() | |
| 312 | 332 | return false | |
| 313 | 333 | } | |
| 314 | 334 | after.revision++ | |
| 315 | - | ac.publishView(after) | |
| 316 | - | return true | |
| 335 | + | s.mu.Unlock() | |
| 336 | + | return ac.publishViewIfCurrent(before, after) | |
| 317 | 337 | } | |
| 318 | 338 | ||
| 319 | 339 | // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab | |
| @@ -347,17 +367,13 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index | |||
| 347 | 367 | ||
| 348 | 368 | // invalidateViewsLocked orders all per-attachment invalidations behind the | |
| 349 | 369 | // shared session mutation. Caller holds s.mu and dispatchMu. | |
| 350 | - | func (s *session) invalidateViewsLocked() []viewInvalidation { | |
| 351 | - | attachments := s.snapshotAttachmentsLocked() | |
| 352 | - | invalidations := make([]viewInvalidation, 0, len(attachments)) | |
| 353 | - | for _, ac := range attachments { | |
| 370 | + | func (s *session) invalidateViewsLocked() { | |
| 371 | + | for _, ac := range s.snapshotAttachmentsLocked() { | |
| 354 | 372 | view := ac.viewSnapshot() | |
| 355 | 373 | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 356 | 374 | repaired.revision++ | |
| 357 | 375 | ac.publishView(repaired) | |
| 358 | - | invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision}) | |
| 359 | 376 | } | |
| 360 | - | return invalidations | |
| 361 | 377 | } | |
| 362 | 378 | ||
| 363 | 379 | // runMutation is the one admission boundary for shared session mutations. | |
| @@ -378,23 +394,31 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||
| 378 | 394 | if s == nil || ac == nil { | |
| 379 | 395 | return nil | |
| 380 | 396 | } | |
| 381 | - | s.mu.Lock() | |
| 382 | - | defer s.mu.Unlock() | |
| 383 | - | if _, ok := s.attachments[ac]; !ok { | |
| 384 | - | return nil | |
| 385 | - | } | |
| 386 | - | view := ac.viewSnapshot() | |
| 387 | - | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 388 | - | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | |
| 389 | - | repaired.revision++ | |
| 390 | - | ac.publishView(repaired) | |
| 391 | - | } | |
| 392 | - | for _, tb := range s.tabs { | |
| 393 | - | if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID { | |
| 394 | - | return tb | |
| 397 | + | for { | |
| 398 | + | view := ac.viewSnapshot() | |
| 399 | + | s.mu.Lock() | |
| 400 | + | if _, ok := s.attachments[ac]; !ok { | |
| 401 | + | s.mu.Unlock() | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | repaired := s.repairAttachmentViewLocked(ac, view) | |
| 405 | + | var target *tab | |
| 406 | + | for _, tb := range s.tabs { | |
| 407 | + | if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID { | |
| 408 | + | target = tb | |
| 409 | + | break | |
| 410 | + | } | |
| 395 | 411 | } | |
| 412 | + | s.mu.Unlock() | |
| 413 | + | ||
| 414 | + | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | |
| 415 | + | repaired.revision++ | |
| 416 | + | } | |
| 417 | + | if !ac.publishViewIfCurrent(view, repaired) { | |
| 418 | + | continue | |
| 419 | + | } | |
| 420 | + | return target | |
| 396 | 421 | } | |
| 397 | - | return nil | |
| 398 | 422 | } | |
| 399 | 423 | ||
| 400 | 424 | // paneForAttachment resolves both stable IDs and repairs a removed pane before | |
| @@ -578,26 +602,24 @@ func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta | |||
| 578 | 602 | ||
| 579 | 603 | // repairAttachmentViews repairs all live attachment targets after a shared | |
| 580 | 604 | // topology mutation. | |
| 581 | - | func (s *session) repairAttachmentViews() []viewInvalidation { | |
| 605 | + | func (s *session) repairAttachmentViews() { | |
| 582 | 606 | if s == nil { | |
| 583 | - | return nil | |
| 607 | + | return | |
| 584 | 608 | } | |
| 585 | - | var invalidations []viewInvalidation | |
| 586 | 609 | _ = s.runMutation(func() error { | |
| 587 | 610 | s.mu.Lock() | |
| 588 | - | invalidations = s.invalidateViewsLocked() | |
| 611 | + | s.invalidateViewsLocked() | |
| 589 | 612 | s.mu.Unlock() | |
| 590 | 613 | return nil | |
| 591 | 614 | }) | |
| 592 | - | return invalidations | |
| 593 | 615 | } | |
| 594 | 616 | ||
| 595 | 617 | // repairAttachmentViewsLocked repairs all live attachment targets after a tab | |
| 596 | - | // or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at | |
| 597 | - | // the same mutation linearization point as the shared membership change. | |
| 598 | - | func (s *session) repairAttachmentViewsLocked() []viewInvalidation { | |
| 618 | + | // or pane mutation. It is intended to run at the same mutation linearization | |
| 619 | + | // point as the shared membership change. | |
| 620 | + | func (s *session) repairAttachmentViewsLocked() { | |
| 599 | 621 | if s == nil { | |
| 600 | - | return nil | |
| 622 | + | return | |
| 601 | 623 | } | |
| 602 | - | return s.invalidateViewsLocked() | |
| 624 | + | s.invalidateViewsLocked() | |
| 603 | 625 | } | |
M internal/usecase/daemon/attachment_registry_test.go +10 -3
| @@ -186,10 +186,17 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T) | |||
| 186 | 186 | t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want) | |
| 187 | 187 | } | |
| 188 | 188 | } | |
| 189 | - | invalidations := sess.repairAttachmentViews() | |
| 189 | + | ordered := sess.repairAttachmentViewsForTest() | |
| 190 | 190 | for i, want := range []byte{1, 2, 3} { | |
| 191 | - | if invalidations[i].attachment.clientID[0] != want { | |
| 192 | - | t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want) | |
| 191 | + | if ordered[i].clientID[0] != want { | |
| 192 | + | t.Fatalf("ordered attachment[%d] id = %d, want %d", i, ordered[i].clientID[0], want) | |
| 193 | 193 | } | |
| 194 | 194 | } | |
| 195 | 195 | } | |
| 196 | + | ||
| 197 | + | func (s *session) repairAttachmentViewsForTest() []*attachedClient { | |
| 198 | + | s.mu.Lock() | |
| 199 | + | defer s.mu.Unlock() | |
| 200 | + | s.repairAttachmentViewsLocked() | |
| 201 | + | return s.snapshotAttachmentsLocked() | |
| 202 | + | } | |
M internal/usecase/daemon/attachment_render_test.go +143 -0
| @@ -31,6 +31,72 @@ func (t *blockedAttachmentTransport) Send(frame ports.Frame) error { | |||
| 31 | 31 | func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 32 | 32 | func (*blockedAttachmentTransport) Close() error { return nil } | |
| 33 | 33 | ||
| 34 | + | func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T) { | |
| 35 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 36 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 37 | + | require.True(t, sess.repairAttachmentView(ac)) | |
| 38 | + | rc.opts.clock = nil // drive fire explicitly; the interleaving is the test. | |
| 39 | + | ||
| 40 | + | firstObserved := make(chan struct{}) | |
| 41 | + | retryObserved := make(chan struct{}) | |
| 42 | + | releaseFirst := make(chan struct{}) | |
| 43 | + | releaseRetry := make(chan struct{}) | |
| 44 | + | var probes atomic.Int32 | |
| 45 | + | rc.opts.ackReadyFor = func(*attachedClient) bool { | |
| 46 | + | switch probes.Add(1) { | |
| 47 | + | case 1: | |
| 48 | + | close(firstObserved) | |
| 49 | + | <-releaseFirst | |
| 50 | + | case 2: | |
| 51 | + | close(retryObserved) | |
| 52 | + | <-releaseRetry | |
| 53 | + | } | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | pane := sess.tabs[0].focusedPane() | |
| 58 | + | pane.mu.Lock() | |
| 59 | + | pane.screen.Write([]byte("capacity race")) | |
| 60 | + | pane.mu.Unlock() | |
| 61 | + | require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "capacity-race-test"})) | |
| 62 | + | fireDone := make(chan struct{}) | |
| 63 | + | go func() { | |
| 64 | + | rc.fireCurrent(false) | |
| 65 | + | close(fireDone) | |
| 66 | + | }() | |
| 67 | + | awaitTestCompletion(t, firstObserved, "readiness probe did not start") | |
| 68 | + | ||
| 69 | + | // Readiness was observed while the window had room. Fill it before the | |
| 70 | + | // wake reaches paint; paint must report the failed final capacity check. | |
| 71 | + | ac.sendMu.Lock() | |
| 72 | + | ac.output.maxOutstanding = 1 | |
| 73 | + | ac.output.next = 1 | |
| 74 | + | ac.output.acked = 0 | |
| 75 | + | ac.output.syncCapacityLocked() | |
| 76 | + | ac.sendMu.Unlock() | |
| 77 | + | close(releaseFirst) | |
| 78 | + | awaitTestCompletion(t, retryObserved, "capacity failure did not schedule an internal retry") | |
| 79 | + | ||
| 80 | + | // The retry is already in flight. Restore capacity without publishing any | |
| 81 | + | // new invalidation or ACK notification, then let that retry paint. | |
| 82 | + | ac.sendMu.Lock() | |
| 83 | + | ac.output.maxOutstanding = 2 | |
| 84 | + | ac.output.syncCapacityLocked() | |
| 85 | + | ac.sendMu.Unlock() | |
| 86 | + | close(releaseRetry) | |
| 87 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 88 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Contains(t, string(output.Data), "capacity race") | |
| 91 | + | awaitTestCompletion(t, fireDone, "initial fire did not finish") | |
| 92 | + | ||
| 93 | + | rc.mu.Lock() | |
| 94 | + | requeuedPending := rc.pending | |
| 95 | + | rc.mu.Unlock() | |
| 96 | + | require.False(t, requeuedPending, "successful internal retry must consume the mutation") | |
| 97 | + | require.Equal(t, int32(2), probes.Load(), "retry must not require another external wake") | |
| 98 | + | } | |
| 99 | + | ||
| 34 | 100 | func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) { | |
| 35 | 101 | pty, releasePTY := newBlockingPTY(t) | |
| 36 | 102 | defer releasePTY() | |
| @@ -76,6 +142,83 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) { | |||
| 76 | 142 | require.Greater(t, second.output.currentEpoch(), secondEpoch) | |
| 77 | 143 | } | |
| 78 | 144 | ||
| 145 | + | func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) { | |
| 146 | + | pty, releasePTY := newBlockingPTY(t) | |
| 147 | + | defer releasePTY() | |
| 148 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 149 | + | d := newTestDaemon(t, newFactory(t, pty), clock) | |
| 150 | + | oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)} | |
| 151 | + | sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | newTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)} | |
| 154 | + | _, fresh, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, newTransport) | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | ||
| 157 | + | rc := sess.renderCoordinator() | |
| 158 | + | oldLease := rc.attachmentLease(old) | |
| 159 | + | freshLease := rc.attachmentLease(fresh) | |
| 160 | + | require.True(t, rc.markAttachmentReady(oldLease)) | |
| 161 | + | oldTransport.blocked.Store(true) | |
| 162 | + | pane := sess.tabs[0].focusedPane() | |
| 163 | + | pane.mu.Lock() | |
| 164 | + | pane.screen.Write([]byte("slow peer")) | |
| 165 | + | pane.mu.Unlock() | |
| 166 | + | require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})) | |
| 167 | + | rc.fireCurrent(false) | |
| 168 | + | awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output") | |
| 169 | + | ||
| 170 | + | require.True(t, rc.markAttachmentReady(freshLease)) | |
| 171 | + | token := sess.attachmentToken(fresh, newTransport) | |
| 172 | + | token.lease = freshLease | |
| 173 | + | painted := make(chan bool, 1) | |
| 174 | + | go func() { painted <- d.firstPaintForTransition(token) }() | |
| 175 | + | frame := awaitTestValue(t, newTransport.sends, "healthy attachment first paint was gated by slow peer") | |
| 176 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |
| 177 | + | require.True(t, <-painted) | |
| 178 | + | close(oldTransport.release) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestMultiAttachmentHandshakeFirstPaintNotGatedByBlockedPeer(t *testing.T) { | |
| 182 | + | pty, releasePTY := newBlockingPTY(t) | |
| 183 | + | defer releasePTY() | |
| 184 | + | clock := &signalClock{timers: make(chan *signalTimer, 16)} | |
| 185 | + | d := newTestDaemon(t, newFactory(t, pty), clock) | |
| 186 | + | oldTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)} | |
| 187 | + | sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, oldTransport) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | rc := sess.renderCoordinator() | |
| 190 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(old))) | |
| 191 | + | ||
| 192 | + | oldTransport.blocked.Store(true) | |
| 193 | + | pane := sess.tabs[0].focusedPane() | |
| 194 | + | pane.mu.Lock() | |
| 195 | + | pane.screen.Write([]byte("slow peer")) | |
| 196 | + | pane.mu.Unlock() | |
| 197 | + | require.True(t, rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})) | |
| 198 | + | fireDone := make(chan struct{}) | |
| 199 | + | go func() { | |
| 200 | + | rc.fireCurrent(false) | |
| 201 | + | close(fireDone) | |
| 202 | + | }() | |
| 203 | + | awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output") | |
| 204 | + | ||
| 205 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}} | |
| 206 | + | tr, sends, releaseConn := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 207 | + | defer releaseConn() | |
| 208 | + | handshakeDone := make(chan struct{}) | |
| 209 | + | go func() { | |
| 210 | + | d.handleConn(tr) | |
| 211 | + | close(handshakeDone) | |
| 212 | + | }() | |
| 213 | + | awaitFrame(t, sends, ports.MsgWelcome) | |
| 214 | + | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 215 | + | require.Equal(t, ports.MsgOutput, firstPaint.Type) | |
| 216 | + | releaseConn() | |
| 217 | + | awaitTestCompletion(t, handshakeDone, "multi-attachment handshake did not complete after first paint") | |
| 218 | + | close(oldTransport.release) | |
| 219 | + | awaitTestCompletion(t, fireDone, "slow peer output did not finish after release") | |
| 220 | + | } | |
| 221 | + | ||
| 79 | 222 | func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) { | |
| 80 | 223 | pty, releasePTY := newBlockingPTY(t) | |
| 81 | 224 | defer releasePTY() | |
M internal/usecase/daemon/attachment_transition.go +4 -4
| @@ -20,8 +20,8 @@ type attachmentLifecycleFence struct { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | type attachmentTransitionRequest struct { | |
| 23 | - | source attachmentSession | |
| 24 | - | target attachmentSession | |
| 23 | + | source *session | |
| 24 | + | target *session | |
| 25 | 25 | next *attachedClient | |
| 26 | 26 | expectedTransport transportSnapshot | |
| 27 | 27 | sourceToken *attachmentConnectionToken | |
| @@ -44,7 +44,7 @@ type attachmentTransitionRequest struct { | |||
| 44 | 44 | attachmentEffectsFrozen bool | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | - | func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool { | |
| 47 | + | func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool { | |
| 48 | 48 | return token.sess == source && token.ac == req.next && | |
| 49 | 49 | token.generation == req.next.connectionGeneration.Load() && | |
| 50 | 50 | token.transport.transport == req.expectedTransport.transport && | |
| @@ -54,7 +54,7 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source | |||
| 54 | 54 | // transitionSourceTokenCurrentLocked requires source.core().mu and, when a | |
| 55 | 55 | // lease exists, sourceCoordinator.mu. It is the canonical exact-connection | |
| 56 | 56 | // handoff check for client-originated navigation and lifecycle mutations. | |
| 57 | - | func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 57 | + | func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 58 | 58 | if token.sess != source || token.ac != req.next || | |
| 59 | 59 | token.generation != req.next.connectionGeneration.Load() || | |
| 60 | 60 | token.sess == nil || token.ac.currentAttachmentSession() != source || | |
M internal/usecase/daemon/attachment_transition_publish.go +22 -27
| @@ -50,16 +50,15 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo | |||
| 50 | 50 | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool { | |
| 53 | + | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 54 | 54 | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | - | func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool { | |
| 57 | + | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | |
| 58 | 58 | if expected == nil { | |
| 59 | 59 | return true | |
| 60 | 60 | } | |
| 61 | - | local, ok := localSession(source) | |
| 62 | - | if !ok || !containsTab(local.tabs, expected) { | |
| 61 | + | if source == nil || !containsTab(source.tabs, expected) { | |
| 63 | 62 | return false | |
| 64 | 63 | } | |
| 65 | 64 | expected.mu.Lock() | |
| @@ -81,7 +80,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at | |||
| 81 | 80 | ||
| 82 | 81 | type attachmentPublication struct { | |
| 83 | 82 | req attachmentTransitionRequest | |
| 84 | - | source attachmentSession | |
| 83 | + | source *session | |
| 85 | 84 | targetCoordinator *renderCoordinator | |
| 86 | 85 | sourceCoordinator *renderCoordinator | |
| 87 | 86 | releaseCoordinators func() | |
| @@ -92,22 +91,21 @@ func (p *attachmentPublication) unlockCoordinators() { | |||
| 92 | 91 | p.releaseCoordinators() | |
| 93 | 92 | } | |
| 94 | 93 | ||
| 95 | - | func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool { | |
| 94 | + | func attachmentLifecycleCurrentLocked(entry *session, fence *attachmentLifecycleFence) bool { | |
| 96 | 95 | if fence == nil { | |
| 97 | 96 | return true | |
| 98 | 97 | } | |
| 99 | - | target, ok := localSession(entry) | |
| 100 | - | if !ok { | |
| 98 | + | if entry == nil { | |
| 101 | 99 | return false | |
| 102 | 100 | } | |
| 103 | - | if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) { | |
| 101 | + | if fence.checkCreatedAt && (entry.name != fence.name || entry.createdAt != fence.createdAt) { | |
| 104 | 102 | return false | |
| 105 | 103 | } | |
| 106 | - | if fence.checkIncarnation && target.incarnation != fence.incarnation { | |
| 104 | + | if fence.checkIncarnation && entry.incarnation != fence.incarnation { | |
| 107 | 105 | return false | |
| 108 | 106 | } | |
| 109 | - | return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) && | |
| 110 | - | domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID | |
| 107 | + | return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(entry.tabs) && | |
| 108 | + | domain.TabStableID(entry.tabs[fence.tabIndex].stableID) == fence.tabID | |
| 111 | 109 | } | |
| 112 | 110 | ||
| 113 | 111 | // validateAttachmentTransitionPrelocked performs every fallible check before | |
| @@ -174,18 +172,12 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 174 | 172 | // shared environment state as part of the same publication. | |
| 175 | 173 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 176 | 174 | req := publication.req | |
| 177 | - | if req.activateTargetTab { | |
| 178 | - | if target, ok := localSession(req.target); ok { | |
| 179 | - | target.activateAttachmentViewLocked(req.next, req.targetTabIndex) | |
| 180 | - | } | |
| 175 | + | if req.activateTargetTab && req.target != nil { | |
| 176 | + | req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex) | |
| 181 | 177 | } | |
| 182 | - | if req.copySourceEnvironment { | |
| 183 | - | source, sourceOK := localSession(publication.source) | |
| 184 | - | target, targetOK := localSession(req.target) | |
| 185 | - | if sourceOK && targetOK { | |
| 186 | - | target.terminal = source.terminal | |
| 187 | - | target.env = copyEnvironment(source.env) | |
| 188 | - | } | |
| 178 | + | if req.copySourceEnvironment && publication.source != nil && req.target != nil { | |
| 179 | + | req.target.terminal = publication.source.terminal | |
| 180 | + | req.target.env = copyEnvironment(publication.source.env) | |
| 189 | 181 | } | |
| 190 | 182 | return req.preserveAttachment | |
| 191 | 183 | } | |
| @@ -259,9 +251,12 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques | |||
| 259 | 251 | } | |
| 260 | 252 | ||
| 261 | 253 | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 262 | - | func lockAttachmentSessions(a, b attachmentSession) func() { | |
| 263 | - | aCore := attachmentSessionCore(a) | |
| 264 | - | bCore := attachmentSessionCore(b) | |
| 254 | + | func lockAttachmentSessions(a, b *session) func() { | |
| 255 | + | if a == nil || b == nil { | |
| 256 | + | return func() {} | |
| 257 | + | } | |
| 258 | + | aCore := a.core() | |
| 259 | + | bCore := b.core() | |
| 265 | 260 | if aCore == nil || bCore == nil { | |
| 266 | 261 | return func() {} | |
| 267 | 262 | } | |
| @@ -283,7 +278,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() { | |||
| 283 | 278 | ||
| 284 | 279 | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 285 | 280 | // lockAttachmentSessions. Callers already hold ordered session locks. | |
| 286 | - | func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() { | |
| 281 | + | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 287 | 282 | if aCoordinator == nil && bCoordinator == nil { | |
| 288 | 283 | return func() {} | |
| 289 | 284 | } | |
M internal/usecase/daemon/attention.go +4 -4
| @@ -90,8 +90,8 @@ type attentionTarget struct { | |||
| 90 | 90 | func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) { | |
| 91 | 91 | d.mu.Lock() | |
| 92 | 92 | sessions := make([]*session, 0, len(d.sessions)) | |
| 93 | - | for _, entry := range d.sessions { | |
| 94 | - | if sess, ok := localSession(entry); ok && sess != current { | |
| 93 | + | for _, sess := range d.sessions { | |
| 94 | + | if sess != nil && sess != current { | |
| 95 | 95 | sessions = append(sessions, sess) | |
| 96 | 96 | } | |
| 97 | 97 | } | |
| @@ -137,7 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||
| 137 | 137 | ||
| 138 | 138 | func (d *Daemon) repaintAllAttachedClients() { | |
| 139 | 139 | d.mu.Lock() | |
| 140 | - | sessions := localSessionsSnapshot(d.sessions) | |
| 140 | + | sessions := sessionsSnapshot(d.sessions) | |
| 141 | 141 | d.mu.Unlock() | |
| 142 | 142 | ||
| 143 | 143 | for _, sess := range sessions { | |
| @@ -245,7 +245,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) { | |||
| 245 | 245 | ||
| 246 | 246 | func (d *Daemon) anyAttention() bool { | |
| 247 | 247 | d.mu.Lock() | |
| 248 | - | sessions := localSessionsSnapshot(d.sessions) | |
| 248 | + | sessions := sessionsSnapshot(d.sessions) | |
| 249 | 249 | d.mu.Unlock() | |
| 250 | 250 | ||
| 251 | 251 | for _, sess := range sessions { | |
M internal/usecase/daemon/bar_refresh.go +2 -3
| @@ -196,7 +196,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() { | |||
| 196 | 196 | func (d *Daemon) sessionsSnapshot() []*session { | |
| 197 | 197 | d.mu.Lock() | |
| 198 | 198 | defer d.mu.Unlock() | |
| 199 | - | return localSessionsSnapshot(d.sessions) | |
| 199 | + | return sessionsSnapshot(d.sessions) | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | 202 | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |
| @@ -390,8 +390,7 @@ func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command | |||
| 390 | 390 | return | |
| 391 | 391 | } | |
| 392 | 392 | attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err} | |
| 393 | - | var scriptErr *barScriptError | |
| 394 | - | if errors.As(err, &scriptErr) { | |
| 393 | + | if scriptErr, ok := errors.AsType[*barScriptError](err); ok { | |
| 395 | 394 | attrs = append(attrs, "exit_code", scriptErr.exitCode) | |
| 396 | 395 | if scriptErr.stderr != "" { | |
| 397 | 396 | attrs = append(attrs, "stderr", scriptErr.stderr) | |
M internal/usecase/daemon/bar_refresh_test.go +1 -1
| @@ -219,7 +219,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 219 | 219 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 220 | 220 | sess := newBarRefreshTestSession() | |
| 221 | 221 | sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}}) | |
| 222 | - | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 222 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 223 | 223 | ||
| 224 | 224 | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| 225 | 225 | // dispatches a run for each. This does not exercise force specifically: | |
M internal/usecase/daemon/client.go +39 -36
| @@ -40,7 +40,7 @@ type attachedClient struct { | |||
| 40 | 40 | overlayOnce sync.Once | |
| 41 | 41 | clientID [16]byte | |
| 42 | 42 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | |
| 43 | - | // the linearization gate for every attachment-bound observable operation. An attachment | |
| 43 | + | // the linearization gate for every attachment-bound observable operation. A role | |
| 44 | 44 | // transition freezes and drains this gate before changing either generation | |
| 45 | 45 | // or the session/coordinator registries. | |
| 46 | 46 | connectionGeneration atomic.Uint64 | |
| @@ -73,7 +73,7 @@ type attachedClient struct { | |||
| 73 | 73 | // tabs and panes without changing shared session ownership. | |
| 74 | 74 | viewMu sync.Mutex | |
| 75 | 75 | view attachmentView | |
| 76 | - | sess Guarded[attachmentSession] | |
| 76 | + | sess Guarded[*session] | |
| 77 | 77 | mouseScan mouse.Scanner | |
| 78 | 78 | themeMu sync.Mutex | |
| 79 | 79 | clientTheme themeui.Theme | |
| @@ -82,7 +82,7 @@ type attachedClient struct { | |||
| 82 | 82 | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 83 | 83 | // previousSession is guarded independently. It is retained through temporary | |
| 84 | 84 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 85 | - | previousSession Guarded[attachmentSession] | |
| 85 | + | previousSession Guarded[*session] | |
| 86 | 86 | linkMu sync.Mutex | |
| 87 | 87 | sendMu sync.Mutex | |
| 88 | 88 | // routeCreatedSession marks a session created by this attachment's route. | |
| @@ -141,15 +141,14 @@ func (ac *attachedClient) initOverlays() { | |||
| 141 | 141 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | |
| 142 | 142 | } | |
| 143 | 143 | ||
| 144 | - | func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() } | |
| 144 | + | func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() } | |
| 145 | 145 | ||
| 146 | 146 | // currentSession narrows the attachment to its owning local session. | |
| 147 | 147 | func (ac *attachedClient) currentSession() *session { | |
| 148 | - | sess, _ := localSession(ac.currentAttachmentSession()) | |
| 149 | - | return sess | |
| 148 | + | return ac.currentAttachmentSession() | |
| 150 | 149 | } | |
| 151 | 150 | ||
| 152 | - | func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) } | |
| 151 | + | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | |
| 153 | 152 | ||
| 154 | 153 | func (ac *attachedClient) clearPreviousSession() { | |
| 155 | 154 | if ac != nil { | |
| @@ -558,13 +557,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 558 | 557 | // attachCoordinator installs the coordinator lease for an attachment. Session | |
| 559 | 558 | // membership remains independent, so installing one attachment never displaces | |
| 560 | 559 | // another. | |
| 561 | - | func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator { | |
| 562 | - | rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready) | |
| 560 | + | func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator { | |
| 561 | + | rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready) | |
| 563 | 562 | cleanup.finish() | |
| 564 | 563 | return rc | |
| 565 | 564 | } | |
| 566 | 565 | ||
| 567 | - | func (d *Daemon) attachCoordinatorDeferred(sess *session, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 566 | + | func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) { | |
| 568 | 567 | rc := d.ensureRenderCoordinator(sess) | |
| 569 | 568 | if current != nil { | |
| 570 | 569 | rc.attachWithReadiness(current, ready) | |
| @@ -588,8 +587,8 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina | |||
| 588 | 587 | } | |
| 589 | 588 | ||
| 590 | 589 | // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an | |
| 591 | - | // exact attachment implementation. The caller holds entry.core().mu. | |
| 592 | - | func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator { | |
| 590 | + | // exact session. The caller holds entry.core().mu. | |
| 591 | + | func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator { | |
| 593 | 592 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 594 | 593 | return rc | |
| 595 | 594 | } | |
| @@ -604,7 +603,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 604 | 603 | // The session snapshot is deterministic. Each paint revalidates the | |
| 605 | 604 | // attachment's membership and transport fence after that snapshot, so | |
| 606 | 605 | // a detached peer cannot stop remaining attachments receiving output. | |
| 607 | - | attachments := snapshotAttachmentSession(entry) | |
| 606 | + | attachments := entry.snapshotAttachments() | |
| 608 | 607 | for _, attachment := range attachments { | |
| 609 | 608 | if !rc.wakeCurrent(w) { | |
| 610 | 609 | return | |
| @@ -614,27 +613,33 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||
| 614 | 613 | continue | |
| 615 | 614 | } | |
| 616 | 615 | // Pane damage is session-shared; every fan-out attachment needs a | |
| 617 | - | // complete frame because any peer may capture it first. A handshake | |
| 618 | - | // target is the one synchronous exception: its first paint must finish | |
| 619 | - | // before the handshake releases its lifecycle gate. | |
| 616 | + | // fresh capture because any peer may acknowledge the damage first. | |
| 617 | + | // Each attachment owns its send lock, so a blocked peer cannot stall | |
| 618 | + | // this fan-out or the PTY reader. A handshake-targeted wake is the | |
| 619 | + | // one synchronous exception: its first paint must finish before the | |
| 620 | + | // handshake deadline is released. | |
| 620 | 621 | fanoutReset := w.reset || len(attachments) > 1 | |
| 622 | + | paint := func() { | |
| 623 | + | if d.paint(entry, attachment, fanoutReset, lease) == paintBlockedCapacity { | |
| 624 | + | rc.retryCapacity(w, lease) | |
| 625 | + | } | |
| 626 | + | } | |
| 621 | 627 | if w.lease != nil && w.lease.attachment == attachment { | |
| 622 | - | d.paint(entry, attachment, fanoutReset, lease) | |
| 628 | + | paint() | |
| 623 | 629 | } else if len(attachments) > 1 { | |
| 624 | - | go d.paint(entry, attachment, fanoutReset, lease) | |
| 625 | - | ||
| 630 | + | go paint() | |
| 626 | 631 | } else { | |
| 627 | - | d.paint(entry, attachment, fanoutReset, lease) | |
| 632 | + | paint() | |
| 628 | 633 | } | |
| 629 | 634 | } | |
| 630 | 635 | }, | |
| 636 | + | ackReadyFor: func(attached *attachedClient) bool { | |
| 637 | + | // outputStateStream publishes capacity atomically. Do not take | |
| 638 | + | // attached.sendMu here: a slow transport may be holding it for an | |
| 639 | + | // in-flight Send, and that peer must not gate healthy attachments. | |
| 640 | + | return attached == nil || attached.output == nil || !attached.output.atCapacity() | |
| 641 | + | }, | |
| 631 | 642 | }) | |
| 632 | - | rc.ackReadyFor = func(attached *attachedClient) bool { | |
| 633 | - | // outputStateStream publishes capacity atomically. Do not take | |
| 634 | - | // attached.sendMu here: a slow transport may be holding it for an | |
| 635 | - | // in-flight Send, and that peer must not gate healthy attachments. | |
| 636 | - | return attached == nil || attached.output == nil || !attached.output.atCapacity() | |
| 637 | - | } | |
| 638 | 643 | installAttachmentRenderCoordinator(entry, rc) | |
| 639 | 644 | return rc | |
| 640 | 645 | } | |
| @@ -684,11 +689,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||
| 684 | 689 | token.ac.captureFrames = nil | |
| 685 | 690 | token.ac.sendMu.Unlock() | |
| 686 | 691 | } | |
| 687 | - | sess, ok := localSession(token.sess) | |
| 688 | - | if !ok { | |
| 692 | + | if token.sess == nil { | |
| 689 | 693 | return false | |
| 690 | 694 | } | |
| 691 | - | d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease) | |
| 695 | + | d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease) | |
| 692 | 696 | return true | |
| 693 | 697 | } | |
| 694 | 698 | ||
| @@ -752,8 +756,8 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po | |||
| 752 | 756 | } | |
| 753 | 757 | clientTheme := themeFromMessage(msg) | |
| 754 | 758 | token.ac.setClientTheme(clientTheme) | |
| 755 | - | sess, ok := localSession(token.sess) | |
| 756 | - | if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 759 | + | sess := token.sess | |
| 760 | + | if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 757 | 761 | return false | |
| 758 | 762 | } | |
| 759 | 763 | if !token.attachmentEffectCurrent() { | |
| @@ -799,8 +803,8 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||
| 799 | 803 | ac.pipelineScratch = composeCacheInput{} | |
| 800 | 804 | ac.sendMu.Unlock() | |
| 801 | 805 | ||
| 802 | - | sess, ok := localSession(token.sess) | |
| 803 | - | if !ok { | |
| 806 | + | sess := token.sess | |
| 807 | + | if sess == nil { | |
| 804 | 808 | return false | |
| 805 | 809 | } | |
| 806 | 810 | rc := sess.renderCoordinator() | |
| @@ -847,12 +851,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken | |||
| 847 | 851 | d.notices.routingMu.Unlock() | |
| 848 | 852 | return | |
| 849 | 853 | } | |
| 850 | - | sess, ok := localSession(token.sess) | |
| 851 | - | if !ok { | |
| 854 | + | if token.sess == nil { | |
| 852 | 855 | d.notices.routingMu.Unlock() | |
| 853 | 856 | return | |
| 854 | 857 | } | |
| 855 | - | repaint := d.mutateClientNotice(sess, token.ac, notice) | |
| 858 | + | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | |
| 856 | 859 | d.notices.routingMu.Unlock() | |
| 857 | 860 | if repaint && token.attachmentEffectCurrent() { | |
| 858 | 861 | d.repaintForNotice(token.ac) | |
M internal/usecase/daemon/client_frame_routing.go +5 -9
| @@ -13,7 +13,7 @@ const connectionSnapshotAttempts = 8 | |||
| 13 | 13 | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | |
| 14 | 14 | // handoff lands between those reads. The attachment gate still performs the final | |
| 15 | 15 | // mutation admission after this function returns. | |
| 16 | - | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) { | |
| 16 | + | func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) { | |
| 17 | 17 | for range connectionSnapshotAttempts { | |
| 18 | 18 | if !ac.currentTransportIs(tr) { | |
| 19 | 19 | break | |
| @@ -54,11 +54,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 54 | 54 | if !ok { | |
| 55 | 55 | return | |
| 56 | 56 | } | |
| 57 | - | local, ok := localSession(sess) | |
| 58 | - | if !ok { | |
| 59 | - | return | |
| 60 | - | } | |
| 61 | - | d.clientGone(local, ac, tr, false) | |
| 57 | + | d.clientGone(sess, ac, tr, false) | |
| 62 | 58 | current := ac.currentAttachmentSession() | |
| 63 | 59 | if current == sess || !ac.currentTransportIs(tr) || current == nil { | |
| 64 | 60 | return | |
| @@ -180,8 +176,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 180 | 176 | result.Text = "attached commands cannot override their active session target" | |
| 181 | 177 | return result | |
| 182 | 178 | } | |
| 183 | - | sess, ok := localSession(token.sess) | |
| 184 | - | if !ok || !token.attachmentEffectCurrent() { | |
| 179 | + | sess := token.sess | |
| 180 | + | if sess == nil || !token.attachmentEffectCurrent() { | |
| 185 | 181 | result.Code = ports.ErrNoSuchTarget | |
| 186 | 182 | result.Text = "attached session is no longer active" | |
| 187 | 183 | return result | |
| @@ -221,7 +217,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 221 | 217 | return result | |
| 222 | 218 | } | |
| 223 | 219 | ||
| 224 | - | // resetOutput rebases only the exact proxied attachment and transport admitted | |
| 220 | + | // resetOutput rebases only the exact attachment and transport admitted | |
| 225 | 221 | // by the frame router. sendMu serializes the rebase with every output send; | |
| 226 | 222 | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 227 | 223 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |
M internal/usecase/daemon/client_transport_errors.go +4 -4
| @@ -37,8 +37,8 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic | |||
| 37 | 37 | } | |
| 38 | 38 | token.effect.bindActionEnd(d, "detach") | |
| 39 | 39 | token.effect.End() | |
| 40 | - | sess, ok := localSession(token.sess) | |
| 41 | - | if !ok { | |
| 40 | + | sess := token.sess | |
| 41 | + | if sess == nil { | |
| 42 | 42 | return false | |
| 43 | 43 | } | |
| 44 | 44 | var parkingToken uint64 | |
| @@ -126,8 +126,8 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke | |||
| 126 | 126 | if failed == nil || failed != token.transport.transport { | |
| 127 | 127 | return | |
| 128 | 128 | } | |
| 129 | - | sess, ok := localSession(token.sess) | |
| 130 | - | if !ok { | |
| 129 | + | sess := token.sess | |
| 130 | + | if sess == nil { | |
| 131 | 131 | return | |
| 132 | 132 | } | |
| 133 | 133 | parkingToken := d.markParkingInFlight(sess, token.ac) | |
M internal/usecase/daemon/clipboard.go +4 -4
| @@ -89,8 +89,8 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke | |||
| 89 | 89 | if err != nil { | |
| 90 | 90 | return "", err | |
| 91 | 91 | } | |
| 92 | - | sess, ok := localSession(token.sess) | |
| 93 | - | if !ok { | |
| 92 | + | sess := token.sess | |
| 93 | + | if sess == nil { | |
| 94 | 94 | _ = os.Remove(path) | |
| 95 | 95 | return "", errAttachmentTransition | |
| 96 | 96 | } | |
| @@ -135,8 +135,8 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) { | |
| 138 | - | sess, ok := localSession(token.sess) | |
| 139 | - | if !ok || !token.attachmentEffectCurrent() { | |
| 138 | + | sess := token.sess | |
| 139 | + | if sess == nil || !token.attachmentEffectCurrent() { | |
| 140 | 140 | return | |
| 141 | 141 | } | |
| 142 | 142 | d.injectClipboardPathToTarget(sess, path, &token) | |
M internal/usecase/daemon/clipboard_test.go +1 -1
| @@ -203,7 +203,7 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY { | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 205 | func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) { | |
| 206 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 206 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 207 | 207 | token := sess.attachmentToken(ac, tr) | |
| 208 | 208 | token.lease = rc.attachmentLease(ac) | |
| 209 | 209 | ac.publishAttachmentCapability(token) | |
M internal/usecase/daemon/config.go +1 -1
| @@ -336,7 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | |||
| 336 | 336 | ||
| 337 | 337 | func (d *Daemon) reapplyThemeAllSessions() { | |
| 338 | 338 | d.mu.Lock() | |
| 339 | - | sessions := localSessionsSnapshot(d.sessions) | |
| 339 | + | sessions := sessionsSnapshot(d.sessions) | |
| 340 | 340 | d.mu.Unlock() | |
| 341 | 341 | ||
| 342 | 342 | for _, sess := range sessions { | |
M internal/usecase/daemon/control.go +5 -6
| @@ -186,9 +186,8 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 186 | 186 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |
| 187 | 187 | } | |
| 188 | 188 | var match *session | |
| 189 | - | for _, entry := range d.sessions { | |
| 190 | - | sess, ok := localSession(entry) | |
| 191 | - | if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 189 | + | for _, sess := range d.sessions { | |
| 190 | + | if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 192 | 191 | continue | |
| 193 | 192 | } | |
| 194 | 193 | if match != nil { | |
| @@ -201,7 +200,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 201 | 200 | } | |
| 202 | 201 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |
| 203 | 202 | } | |
| 204 | - | locals := localSessionsSnapshot(d.sessions) | |
| 203 | + | locals := sessionsSnapshot(d.sessions) | |
| 205 | 204 | if len(locals) == 0 { | |
| 206 | 205 | return nil, ports.ErrNoSuchTarget, "no live sessions" | |
| 207 | 206 | } | |
| @@ -658,7 +657,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||
| 658 | 657 | Active bool `json:"active"` | |
| 659 | 658 | } | |
| 660 | 659 | e.d.mu.Lock() | |
| 661 | - | sessions := localSessionsSnapshot(e.d.sessions) | |
| 660 | + | sessions := sessionsSnapshot(e.d.sessions) | |
| 662 | 661 | e.d.mu.Unlock() | |
| 663 | 662 | snaps := make([]sessionView, 0, len(sessions)) | |
| 664 | 663 | for _, sess := range sessions { | |
| @@ -695,7 +694,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 695 | 694 | return "", command.ErrInvalidArguments | |
| 696 | 695 | } | |
| 697 | 696 | e.d.mu.Lock() | |
| 698 | - | sessions := localSessionsSnapshot(e.d.sessions) | |
| 697 | + | sessions := sessionsSnapshot(e.d.sessions) | |
| 699 | 698 | e.d.mu.Unlock() | |
| 700 | 699 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |
| 701 | 700 | // Stopped sessions live in d.stopped and are intentionally not in this listing. | |
M internal/usecase/daemon/daemon.go +8 -9
| @@ -70,7 +70,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24} | |||
| 70 | 70 | ||
| 71 | 71 | type Daemon struct { | |
| 72 | 72 | mu sync.Mutex | |
| 73 | - | sessions map[domain.SessionID]attachmentSession | |
| 73 | + | sessions map[domain.SessionID]*session | |
| 74 | 74 | stopped map[string]stoppedSession | |
| 75 | 75 | // creating reserves names while durable creation I/O runs without mu. | |
| 76 | 76 | creating map[string]struct{} | |
| @@ -144,7 +144,7 @@ type Daemon struct { | |||
| 144 | 144 | afterResizeCommitSendLocked func() | |
| 145 | 145 | // afterConnectionSessionSnapshot is a deterministic test seam after the | |
| 146 | 146 | // connection loop reads its current session and before it captures that role. | |
| 147 | - | afterConnectionSessionSnapshot func(attachmentSession) | |
| 147 | + | afterConnectionSessionSnapshot func(*session) | |
| 148 | 148 | // afterAttachmentFrameDispatch is a deterministic test seam after the | |
| 149 | 149 | // connection loop snapshots a token and before a decoded frame takes effect. | |
| 150 | 150 | afterAttachmentFrameDispatch func(attachmentConnectionToken) | |
| @@ -586,7 +586,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 586 | 586 | } | |
| 587 | 587 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 588 | 588 | d := &Daemon{ | |
| 589 | - | sessions: make(map[domain.SessionID]attachmentSession), | |
| 589 | + | sessions: make(map[domain.SessionID]*session), | |
| 590 | 590 | stopped: make(map[string]stoppedSession), | |
| 591 | 591 | creating: make(map[string]struct{}), | |
| 592 | 592 | parked: make(map[uint64]*parkedAttachment), | |
| @@ -914,7 +914,7 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c | |||
| 914 | 914 | ||
| 915 | 915 | // registerSessionLocked publishes an exact attachment-session identity. Caller | |
| 916 | 916 | // holds d.mu. | |
| 917 | - | func (d *Daemon) registerSessionLocked(entry attachmentSession) bool { | |
| 917 | + | func (d *Daemon) registerSessionLocked(entry *session) bool { | |
| 918 | 918 | if entry == nil { | |
| 919 | 919 | return false | |
| 920 | 920 | } | |
| @@ -928,7 +928,7 @@ func (d *Daemon) registerSessionLocked(entry attachmentSession) bool { | |||
| 928 | 928 | ||
| 929 | 929 | // unregisterSessionLocked removes only the exact registered identity. Caller | |
| 930 | 930 | // holds d.mu. | |
| 931 | - | func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool { | |
| 931 | + | func (d *Daemon) unregisterSessionLocked(entry *session) bool { | |
| 932 | 932 | if entry == nil { | |
| 933 | 933 | return false | |
| 934 | 934 | } | |
| @@ -941,7 +941,7 @@ func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool { | |||
| 941 | 941 | } | |
| 942 | 942 | ||
| 943 | 943 | func (d *Daemon) sessionsSnapshotLocked() []*session { | |
| 944 | - | return localSessionsSnapshot(d.sessions) | |
| 944 | + | return sessionsSnapshot(d.sessions) | |
| 945 | 945 | } | |
| 946 | 946 | ||
| 947 | 947 | // handleConn reads the first frame off a fresh connection and routes it. A | |
| @@ -1001,9 +1001,8 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 1001 | 1001 | d.mu.Lock() | |
| 1002 | 1002 | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | |
| 1003 | 1003 | liveNames := make(map[string]struct{}, len(d.sessions)) | |
| 1004 | - | for _, entry := range d.sessions { | |
| 1005 | - | s, ok := localSession(entry) | |
| 1006 | - | if !ok { | |
| 1004 | + | for _, s := range d.sessions { | |
| 1005 | + | if s == nil { | |
| 1007 | 1006 | continue | |
| 1008 | 1007 | } | |
| 1009 | 1008 | s.mu.Lock() | |
M internal/usecase/daemon/daemon_test.go +2 -2
| @@ -297,8 +297,8 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram | |||
| 297 | 297 | func firstSession(d *Daemon) *session { | |
| 298 | 298 | d.mu.Lock() | |
| 299 | 299 | defer d.mu.Unlock() | |
| 300 | - | for _, entry := range d.sessions { | |
| 301 | - | if s, ok := localSession(entry); ok { | |
| 300 | + | for _, s := range d.sessions { | |
| 301 | + | if s != nil { | |
| 302 | 302 | return s | |
| 303 | 303 | } | |
| 304 | 304 | } | |
A internal/usecase/daemon/final_acceptance_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 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 TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(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 | + | secondTab := newTabWithStableID("tab-second", "pane-second", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 29 | + | secondTab.ctx, secondTab.cancel = sess.ctx, func() {} | |
| 30 | + | publishTiledPaneOwners(sess, secondTab) | |
| 31 | + | require.NoError(t, sess.runMutation(func() error { | |
| 32 | + | sess.mu.Lock() | |
| 33 | + | defer sess.mu.Unlock() | |
| 34 | + | sess.tabs = append(sess.tabs, secondTab) | |
| 35 | + | return nil | |
| 36 | + | })) | |
| 37 | + | require.True(t, sess.registerAttachment(second)) | |
| 38 | + | require.True(t, sess.selectAttachmentTab(first, domain.TabStableID(firstTab.stableID))) | |
| 39 | + | require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(secondTab.stableID))) | |
| 40 | + | ||
| 41 | + | d.ptys = newFactory(t, newQuietPTY()) | |
| 42 | + | rc := d.attachCoordinator(sess, nil, first, true) | |
| 43 | + | d.attachCoordinator(sess, nil, second, true) | |
| 44 | + | firstToken := sess.attachmentToken(first, first.transport()) | |
| 45 | + | firstToken.lease = rc.attachmentLease(first) | |
| 46 | + | first.publishAttachmentCapability(firstToken) | |
| 47 | + | secondToken := sess.attachmentToken(second, second.transport()) | |
| 48 | + | secondToken.lease = rc.attachmentLease(second) | |
| 49 | + | second.publishAttachmentCapability(secondToken) | |
| 50 | + | ||
| 51 | + | firstView := first.viewSnapshot() | |
| 52 | + | secondView := second.viewSnapshot() | |
| 53 | + | first.sendMu.Lock() | |
| 54 | + | firstEpoch := first.output.currentEpoch() | |
| 55 | + | first.sendMu.Unlock() | |
| 56 | + | require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40})) | |
| 57 | + | ||
| 58 | + | d.enterPalette(sess, second) | |
| 59 | + | d.handlePaletteInput(second, []byte("CNT\r")) | |
| 60 | + | ||
| 61 | + | sess.mu.Lock() | |
| 62 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 63 | + | sess.mu.Unlock() | |
| 64 | + | require.Len(t, tabs, 3) | |
| 65 | + | wantContent := domain.Size{Cols: 80, Rows: 23} | |
| 66 | + | for _, tb := range tabs { | |
| 67 | + | tb.mu.Lock() | |
| 68 | + | gotTabSize := tb.size | |
| 69 | + | pane := tb.focusedPane() | |
| 70 | + | pane.mu.Lock() | |
| 71 | + | gotPaneSize := domain.Size{Cols: pane.screen.Frame.Width, Rows: pane.screen.Frame.Height} | |
| 72 | + | pane.mu.Unlock() | |
| 73 | + | tb.mu.Unlock() | |
| 74 | + | require.Equal(t, wantContent, gotTabSize) | |
| 75 | + | require.Equal(t, wantContent, gotPaneSize) | |
| 76 | + | } | |
| 77 | + | require.Equal(t, firstView.tabID, first.viewSnapshot().tabID, "new tab selection crossed attachment boundary") | |
| 78 | + | require.NotEqual(t, secondView.tabID, second.viewSnapshot().tabID, "palette did not select the created tab") | |
| 79 | + | first.sendMu.Lock() | |
| 80 | + | gotFirstEpoch := first.output.currentEpoch() | |
| 81 | + | first.sendMu.Unlock() | |
| 82 | + | require.Equal(t, firstEpoch, gotFirstEpoch, "new tab render crossed attachment boundary") | |
| 83 | + | second.sendMu.Lock() | |
| 84 | + | gotSecondSize := second.size | |
| 85 | + | second.sendMu.Unlock() | |
| 86 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, gotSecondSize) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing.T) { | |
| 90 | + | d, sess, first, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 91 | + | first.clientID[0] = 1 | |
| 92 | + | firstTab := sess.tabs[0] | |
| 93 | + | firstTab.stableID = "tab-first" | |
| 94 | + | firstTab.panes[firstTab.focusedPane().id].stableID = "pane-first" | |
| 95 | + | ||
| 96 | + | secondTransport, _ := newCapturingTransport(t) | |
| 97 | + | second := &attachedClient{ | |
| 98 | + | tr: secondTransport, | |
| 99 | + | output: newOutputStateStream(), | |
| 100 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 101 | + | } | |
| 102 | + | second.initOverlays() | |
| 103 | + | second.clientID[0] = 2 | |
| 104 | + | second.setSession(sess) | |
| 105 | + | ||
| 106 | + | secondTab := newTabWithStableID("tab-second", "pane-second", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 107 | + | secondTab.ctx, secondTab.cancel = sess.ctx, func() {} | |
| 108 | + | publishTiledPaneOwners(sess, secondTab) | |
| 109 | + | require.NoError(t, sess.runMutation(func() error { | |
| 110 | + | sess.mu.Lock() | |
| 111 | + | defer sess.mu.Unlock() | |
| 112 | + | sess.tabs = append(sess.tabs, secondTab) | |
| 113 | + | return nil | |
| 114 | + | })) | |
| 115 | + | require.True(t, sess.registerAttachment(second)) | |
| 116 | + | require.True(t, sess.selectAttachmentTab(first, domain.TabStableID(firstTab.stableID))) | |
| 117 | + | require.True(t, sess.selectAttachmentTab(second, domain.TabStableID(secondTab.stableID))) | |
| 118 | + | ||
| 119 | + | firstBefore := first.viewSnapshot() | |
| 120 | + | secondBefore := second.viewSnapshot() | |
| 121 | + | require.True(t, sess.updateAttachmentView(first, func(view *attachmentView) { | |
| 122 | + | view.windowTop = 7 | |
| 123 | + | view.windowRows = 12 | |
| 124 | + | view.bookmark = 11 | |
| 125 | + | view.liveBottom = false | |
| 126 | + | })) | |
| 127 | + | gotFirst := first.viewSnapshot() | |
| 128 | + | gotSecond := second.viewSnapshot() | |
| 129 | + | require.Equal(t, domain.TabStableID(firstTab.stableID), gotFirst.tabID) | |
| 130 | + | require.Equal(t, 7, gotFirst.windowTop) | |
| 131 | + | require.Equal(t, vt.RowID(11), gotFirst.bookmark) | |
| 132 | + | require.False(t, gotFirst.liveBottom) | |
| 133 | + | require.Equal(t, secondBefore, gotSecond, "first attachment view mutation leaked to peer") | |
| 134 | + | require.NotEqual(t, firstBefore.revision, gotFirst.revision) | |
| 135 | + | ||
| 136 | + | rc := d.attachCoordinator(sess, nil, first, true) | |
| 137 | + | d.attachCoordinator(sess, nil, second, true) | |
| 138 | + | firstToken := sess.attachmentToken(first, first.transport()) | |
| 139 | + | firstToken.lease = rc.attachmentLease(first) | |
| 140 | + | first.publishAttachmentCapability(firstToken) | |
| 141 | + | secondToken := sess.attachmentToken(second, second.transport()) | |
| 142 | + | secondToken.lease = rc.attachmentLease(second) | |
| 143 | + | second.publishAttachmentCapability(secondToken) | |
| 144 | + | firstEpoch := first.output.currentEpoch() | |
| 145 | + | secondEpoch := second.output.currentEpoch() | |
| 146 | + | ||
| 147 | + | d.handleAttachmentClientFrame(secondToken, ports.Frame{ | |
| 148 | + | Type: ports.MsgOutputResetRequest, | |
| 149 | + | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 150 | + | }) | |
| 151 | + | require.Greater(t, second.output.currentEpoch(), secondEpoch) | |
| 152 | + | require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary") | |
| 153 | + | ||
| 154 | + | require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40})) | |
| 155 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size) | |
| 156 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary") | |
| 157 | + | sess.mu.Lock() | |
| 158 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, sess.tabs[0].size, "attachment resize mutated shared session content") | |
| 159 | + | sess.mu.Unlock() | |
| 160 | + | ||
| 161 | + | d.clientGone(sess, first, first.transport(), true) | |
| 162 | + | require.NotContains(t, sess.snapshotAttachments(), first) | |
| 163 | + | require.Contains(t, sess.snapshotAttachments(), second, "detaching one attachment removed its peer") | |
| 164 | + | require.Same(t, sess, second.currentSession()) | |
| 165 | + | } | |
M internal/usecase/daemon/input.go +3 -5
| @@ -383,8 +383,7 @@ type daemonKeyHandler struct { | |||
| 383 | 383 | func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) { | |
| 384 | 384 | if h.connectionToken.ac != nil { | |
| 385 | 385 | if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() { | |
| 386 | - | sess, _ := localSession(h.connectionToken.sess) | |
| 387 | - | return sess, effect, false | |
| 386 | + | return h.connectionToken.sess, effect, false | |
| 388 | 387 | } | |
| 389 | 388 | effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken) | |
| 390 | 389 | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| @@ -398,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect | |||
| 398 | 397 | token.effect = effect | |
| 399 | 398 | h.d.afterAttachmentEffectAdmitted(token) | |
| 400 | 399 | } | |
| 401 | - | sess, ok := localSession(h.connectionToken.sess) | |
| 402 | - | if !ok { | |
| 400 | + | if h.connectionToken.sess == nil { | |
| 403 | 401 | effect.End() | |
| 404 | 402 | return nil, nil, false | |
| 405 | 403 | } | |
| 406 | - | return sess, effect, true | |
| 404 | + | return h.connectionToken.sess, effect, true | |
| 407 | 405 | } | |
| 408 | 406 | sess := h.ac.currentSession() | |
| 409 | 407 | if sess == nil { | |
M internal/usecase/daemon/move.go +2 -2
| @@ -180,8 +180,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session | |||
| 180 | 180 | if d == nil { | |
| 181 | 181 | return nil | |
| 182 | 182 | } | |
| 183 | - | sess, ok := localSession(d.sessions[locator.ID]) | |
| 184 | - | if !ok || sess.incarnation != locator.Incarnation { | |
| 183 | + | sess := d.sessions[locator.ID] | |
| 184 | + | if sess == nil || sess.incarnation != locator.Incarnation { | |
| 185 | 185 | return nil | |
| 186 | 186 | } | |
| 187 | 187 | return sess | |
M internal/usecase/daemon/move_cleanup_test.go +2 -2
| @@ -35,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 35 | 35 | defer releaseDestination() | |
| 36 | 36 | ||
| 37 | 37 | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 38 | - | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 38 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 39 | 39 | sourceTab := source.tabs[0] | |
| 40 | 40 | sourceTab.stableID = "source-tab" | |
| 41 | 41 | moved := sourceTab.focusedPane() | |
| @@ -117,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 117 | 117 | sourceTab.stableID = "source-tab" | |
| 118 | 118 | remainingTab.stableID = "remaining-tab" | |
| 119 | 119 | moved := sourceTab.focusedPane() | |
| 120 | - | require.NotNil(t, d.attachCoordinator(source, displaced, true)) | |
| 120 | + | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | |
| 121 | 121 | ||
| 122 | 122 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 123 | 123 | destinationTab := destination.tabs[0] | |
M internal/usecase/daemon/move_gate_race_test.go +3 -3
| @@ -19,7 +19,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess | |||
| 19 | 19 | sourceTab := source.tabs[0] | |
| 20 | 20 | sourceTab.stableID = "source-tab" | |
| 21 | 21 | moved := sourceTab.focusedPane() | |
| 22 | - | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 22 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 23 | 23 | ||
| 24 | 24 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 25 | 25 | destinationTab := destination.tabs[0] | |
| @@ -184,7 +184,7 @@ func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) { | |||
| 184 | 184 | ||
| 185 | 185 | func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) { | |
| 186 | 186 | d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 187 | - | d.attachCoordinator(sess, current, true) | |
| 187 | + | d.attachCoordinator(sess, nil, current, true) | |
| 188 | 188 | otherTransport := &closeTrackingTransport{} | |
| 189 | 189 | other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size} | |
| 190 | 190 | other.initOverlays() | |
| @@ -192,7 +192,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) { | |||
| 192 | 192 | sess.mu.Lock() | |
| 193 | 193 | require.True(t, sess.registerAttachmentLocked(other)) | |
| 194 | 194 | sess.mu.Unlock() | |
| 195 | - | d.attachCoordinator(sess, other, true) | |
| 195 | + | d.attachCoordinator(sess, nil, other, true) | |
| 196 | 196 | ||
| 197 | 197 | currentToken := sess.attachmentToken(current, current.transport()) | |
| 198 | 198 | currentEffect, admitted := current.beginAttachmentEffect(currentToken) | |
M internal/usecase/daemon/move_picker.go +4 -4
| @@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |||
| 10 | 10 | if d == nil || source.Session.ID == "" || source.TabID == "" { | |
| 11 | 11 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 12 | 12 | } | |
| 13 | - | sess, ok := localSession(d.sessionByID(source.Session.ID)) | |
| 14 | - | if !ok || sess.incarnation != source.Session.Incarnation { | |
| 13 | + | sess := d.sessionByID(source.Session.ID) | |
| 14 | + | if sess == nil || sess.incarnation != source.Session.Incarnation { | |
| 15 | 15 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 16 | 16 | } | |
| 17 | 17 | sess.mu.Lock() | |
| @@ -120,8 +120,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 120 | 120 | ||
| 121 | 121 | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | |
| 122 | 122 | d.mu.Lock() | |
| 123 | - | sess, ok := localSession(d.sessions[target.Session]) | |
| 124 | - | if !ok { | |
| 123 | + | sess := d.sessions[target.Session] | |
| 124 | + | if sess == nil { | |
| 125 | 125 | d.mu.Unlock() | |
| 126 | 126 | return nil, nil | |
| 127 | 127 | } | |
M internal/usecase/daemon/move_tab_test.go +2 -2
| @@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing | |||
| 152 | 152 | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 153 | 153 | moved := source.tabs[0] | |
| 154 | 154 | moved.stableID = "moved-tab" | |
| 155 | - | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 155 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 156 | 156 | destination := addMoveTabTestSession(d, "destination", "destination-tab") | |
| 157 | 157 | floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8}) | |
| 158 | 158 | moved.mu.Lock() | |
| @@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain. | |||
| 453 | 453 | ||
| 454 | 454 | func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) { | |
| 455 | 455 | d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 456 | - | require.NotNil(t, d.attachCoordinator(source, client, true)) | |
| 456 | + | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 457 | 457 | source.incarnation = domain.IncarnationID{4} | |
| 458 | 458 | moved := source.tabs[0] | |
| 459 | 459 | moved.stableID = "moved-tab" | |
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
| @@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 97 | 97 | d.sessions[target.id] = target | |
| 98 | 98 | d.mu.Unlock() | |
| 99 | 99 | ||
| 100 | - | rc := d.attachCoordinator(source, old, true) | |
| 100 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 101 | 101 | token := source.attachmentToken(old, old.transport()) | |
| 102 | 102 | token.lease = rc.attachmentLease(old) | |
| 103 | 103 | old.publishAttachmentCapability(token) | |
| @@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 152 | 152 | d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped} | |
| 153 | 153 | d.mu.Unlock() | |
| 154 | 154 | ||
| 155 | - | rc := d.attachCoordinator(source, old, true) | |
| 155 | + | rc := d.attachCoordinator(source, nil, old, true) | |
| 156 | 156 | token := source.attachmentToken(old, old.transport()) | |
| 157 | 157 | token.lease = rc.attachmentLease(old) | |
| 158 | 158 | old.publishAttachmentCapability(token) | |
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +1 -1
| @@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 47 | 47 | d.sessions[target.id] = target | |
| 48 | 48 | d.mu.Unlock() | |
| 49 | 49 | ||
| 50 | - | rc := d.attachCoordinator(source, ac, true) | |
| 50 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 51 | 51 | token := source.attachmentToken(ac, ac.transport()) | |
| 52 | 52 | token.lease = rc.attachmentLease(ac) | |
| 53 | 53 | ac.publishAttachmentCapability(token) | |
M internal/usecase/daemon/navigation_overflow.go +1 -1
| @@ -85,7 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, | |||
| 85 | 85 | current.mu.Unlock() | |
| 86 | 86 | ||
| 87 | 87 | d.mu.Lock() | |
| 88 | - | live := localSessionsSnapshot(d.sessions) | |
| 88 | + | live := sessionsSnapshot(d.sessions) | |
| 89 | 89 | d.mu.Unlock() | |
| 90 | 90 | ||
| 91 | 91 | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | |
M internal/usecase/daemon/navigation_overflow_test.go +4 -6
| @@ -152,9 +152,8 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 152 | 152 | d.mu.Lock() | |
| 153 | 153 | _, stopped := d.stopped["bravo"] | |
| 154 | 154 | var bravoLive bool | |
| 155 | - | for _, entry := range d.sessions { | |
| 156 | - | candidate, ok := localSession(entry) | |
| 157 | - | if !ok { | |
| 155 | + | for _, candidate := range d.sessions { | |
| 156 | + | if candidate == nil { | |
| 158 | 157 | continue | |
| 159 | 158 | } | |
| 160 | 159 | candidate.mu.Lock() | |
| @@ -322,9 +321,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 322 | 321 | current := ac.currentSession() | |
| 323 | 322 | owners := make([]*session, 0, 1) | |
| 324 | 323 | d.mu.Lock() | |
| 325 | - | for _, entry := range d.sessions { | |
| 326 | - | candidate, ok := localSession(entry) | |
| 327 | - | if !ok { | |
| 324 | + | for _, candidate := range d.sessions { | |
| 325 | + | if candidate == nil { | |
| 328 | 326 | continue | |
| 329 | 327 | } | |
| 330 | 328 | candidate.mu.Lock() | |
M internal/usecase/daemon/notices_overlay.go +4 -4
| @@ -17,7 +17,7 @@ var noticesModal = ui.Modal{WidthPct: 70, HeightPct: 70, MinWidth: 40, MinHeight | |||
| 17 | 17 | ||
| 18 | 18 | // enterNotices opens the notification history overlay from the daemon's full | |
| 19 | 19 | // notice center history, newest first. | |
| 20 | - | func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) { | |
| 20 | + | func (d *Daemon) enterNotices(sess *session, ac *attachedClient) { | |
| 21 | 21 | history := d.notices.history() | |
| 22 | 22 | now := d.clock.Now() | |
| 23 | 23 | ac.overlays.noticeMu.Lock() | |
| @@ -221,13 +221,13 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) { | |||
| 221 | 221 | // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput): | |
| 222 | 222 | // send each chunk, then leave a one-shot status-bar confirmation that the | |
| 223 | 223 | // next repaint clears. | |
| 224 | - | func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domain.Notification) { | |
| 224 | + | func (d *Daemon) yankNotice(entry *session, ac *attachedClient, n domain.Notification) { | |
| 225 | 225 | chunks := scopy.OSC52(noticeYankPayload(n)) | |
| 226 | 226 | for _, chunk := range chunks { | |
| 227 | 227 | failed, err := d.boundedSendOutputErrTransport(ac, chunk) | |
| 228 | 228 | if err != nil { | |
| 229 | - | if sess, ok := localSession(entry); ok { | |
| 230 | - | d.detachOnSendError(sess, ac, failed) | |
| 229 | + | if entry != nil { | |
| 230 | + | d.detachOnSendError(entry, ac, failed) | |
| 231 | 231 | } else { | |
| 232 | 232 | _ = ac.closeCapturedTransport(failed) | |
| 233 | 233 | } | |
M internal/usecase/daemon/notify.go +7 -5
| @@ -236,9 +236,8 @@ func (d *Daemon) reportError(sess *session, err error) { | |||
| 236 | 236 | d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err) | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | - | func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) { | |
| 240 | - | local, _ := localSession(entry) | |
| 241 | - | d.reportError(local, err) | |
| 239 | + | func (d *Daemon) reportAttachmentError(entry *session, err error) { | |
| 240 | + | d.reportError(entry, err) | |
| 242 | 241 | } | |
| 243 | 242 | ||
| 244 | 243 | // notify records a notice and routes it to whoever can see it. A nil sess means | |
| @@ -300,7 +299,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 300 | 299 | func (d *Daemon) deliverGlobal(n domain.Notification) { | |
| 301 | 300 | d.mu.Lock() | |
| 302 | 301 | d.notices.routingMu.Lock() | |
| 303 | - | sessions := make([]attachmentSession, 0, len(d.sessions)) | |
| 302 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 304 | 303 | for _, entry := range d.sessions { | |
| 305 | 304 | sessions = append(sessions, entry) | |
| 306 | 305 | } | |
| @@ -311,7 +310,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) { | |||
| 311 | 310 | } | |
| 312 | 311 | targets := make([]*attachedClient, 0, len(sessions)) | |
| 313 | 312 | for _, s := range sessions { | |
| 314 | - | for _, ac := range snapshotAttachmentSession(s) { | |
| 313 | + | if s == nil { | |
| 314 | + | continue | |
| 315 | + | } | |
| 316 | + | for _, ac := range s.snapshotAttachments() { | |
| 315 | 317 | if !d.publishToast(ac, n) { | |
| 316 | 318 | continue | |
| 317 | 319 | } | |
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -52,7 +52,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing. | |||
| 52 | 52 | ||
| 53 | 53 | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 54 | 54 | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 55 | - | d.attachCoordinator(sess, ac, true) | |
| 55 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 56 | 56 | tr, ok := ac.tr.(*portsmocks.MockTransport) | |
| 57 | 57 | require.True(t, ok, "attached client transport must be a MockTransport") | |
| 58 | 58 | frames := []ports.Frame{ | |
M internal/usecase/daemon/output_state_stream.go +6 -3
| @@ -71,7 +71,6 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |||
| 71 | 71 | maxOutstanding: uint64(window), | |
| 72 | 72 | } | |
| 73 | 73 | stream.maxOutstandingAtomic.Store(uint64(window)) | |
| 74 | - | stream.publishOutstanding() | |
| 75 | 74 | return stream | |
| 76 | 75 | } | |
| 77 | 76 | ||
| @@ -287,8 +286,8 @@ func (s *outputStateStream) rebaseLocked() { | |||
| 287 | 286 | s.next = 0 | |
| 288 | 287 | s.acked = 0 | |
| 289 | 288 | s.generation++ | |
| 290 | - | s.maxOutstandingAtomic.Store(s.maxOutstanding) | |
| 291 | 289 | s.publishOutstanding() | |
| 290 | + | s.maxOutstandingAtomic.Store(s.maxOutstanding) | |
| 292 | 291 | s.renderer.Reset() | |
| 293 | 292 | s.forceSnapshot = true | |
| 294 | 293 | s.initialized = false | |
| @@ -317,7 +316,11 @@ func (s *outputStateStream) atCapacity() bool { | |||
| 317 | 316 | if s == nil { | |
| 318 | 317 | return false | |
| 319 | 318 | } | |
| 320 | - | return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load() | |
| 319 | + | maxOutstanding := s.maxOutstandingAtomic.Load() | |
| 320 | + | if maxOutstanding == 0 { | |
| 321 | + | maxOutstanding = s.maxOutstanding | |
| 322 | + | } | |
| 323 | + | return s.outstandingAtomic.Load() >= maxOutstanding | |
| 321 | 324 | } | |
| 322 | 325 | ||
| 323 | 326 | func (s *outputStateStream) outstanding() uint64 { | |
M internal/usecase/daemon/palette.go +11 -25
| @@ -150,7 +150,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 150 | 150 | if entry == nil { | |
| 151 | 151 | return | |
| 152 | 152 | } | |
| 153 | - | sess, local := localSession(entry) | |
| 153 | + | sess := entry | |
| 154 | 154 | var cmd command.Command | |
| 155 | 155 | var sessionTarget palette.Result | |
| 156 | 156 | var hasSessionTarget bool | |
| @@ -276,10 +276,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 276 | 276 | var err error | |
| 277 | 277 | if effect != nil { | |
| 278 | 278 | err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session") | |
| 279 | - | } else if local { | |
| 280 | - | err = d.switchToTarget(sess, ac, target) | |
| 281 | 279 | } else { | |
| 282 | - | err = errAttachmentTransition | |
| 280 | + | err = d.switchToTarget(sess, ac, target) | |
| 283 | 281 | } | |
| 284 | 282 | if errors.Is(err, errAttachmentTransition) { | |
| 285 | 283 | return | |
| @@ -317,12 +315,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 317 | 315 | } | |
| 318 | 316 | return | |
| 319 | 317 | } | |
| 320 | - | if !local { | |
| 321 | - | if ac.closeExecutedPalette(generation, rawQuery) { | |
| 322 | - | d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil) | |
| 323 | - | } | |
| 324 | - | return | |
| 325 | - | } | |
| 326 | 318 | attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach" | |
| 327 | 319 | if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) { | |
| 328 | 320 | return | |
| @@ -401,7 +393,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||
| 401 | 393 | type paletteExec struct { | |
| 402 | 394 | d *Daemon | |
| 403 | 395 | sess *session | |
| 404 | - | attachment attachmentSession | |
| 396 | + | attachment *session | |
| 405 | 397 | ac *attachedClient | |
| 406 | 398 | recent []recentSession | |
| 407 | 399 | actions daemonActionRunner | |
| @@ -435,7 +427,7 @@ func (e paletteExec) runAction(request daemonActionRequest) error { | |||
| 435 | 427 | } | |
| 436 | 428 | ||
| 437 | 429 | func (e paletteExec) CreateTab() error { | |
| 438 | - | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.ac.size}) | |
| 430 | + | return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()}) | |
| 439 | 431 | } | |
| 440 | 432 | ||
| 441 | 433 | func (e paletteExec) CreateSession() error { | |
| @@ -445,11 +437,10 @@ func (e paletteExec) CreateSession() error { | |||
| 445 | 437 | } | |
| 446 | 438 | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error { | |
| 447 | 439 | if token.ac == nil { | |
| 448 | - | sess, ok := e.localSession() | |
| 449 | - | if !ok { | |
| 440 | + | if e.sess == nil { | |
| 450 | 441 | return errAttachmentTransition | |
| 451 | 442 | } | |
| 452 | - | return e.d.createSessionAndSwitch(sess, e.ac, name) | |
| 443 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |
| 453 | 444 | } | |
| 454 | 445 | return e.d.createSessionAndSwitchForAttachment(token, name) | |
| 455 | 446 | }) | |
| @@ -633,14 +624,6 @@ func (e paletteExec) YankLastNotification() error { | |||
| 633 | 624 | return nil | |
| 634 | 625 | } | |
| 635 | 626 | ||
| 636 | - | func (e paletteExec) localSession() (*session, bool) { | |
| 637 | - | entry := e.attachment | |
| 638 | - | if entry == nil { | |
| 639 | - | entry = e.sess | |
| 640 | - | } | |
| 641 | - | return localSession(entry) | |
| 642 | - | } | |
| 643 | - | ||
| 644 | 627 | func (e paletteExec) JumpRecentSession(rank int) error { | |
| 645 | 628 | if rank < 1 || rank > len(e.recent) { | |
| 646 | 629 | return command.ErrInvalidArguments | |
| @@ -656,8 +639,11 @@ func (e paletteExec) JumpRecentSession(rank int) error { | |||
| 656 | 639 | if e.effect != nil { | |
| 657 | 640 | err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session") | |
| 658 | 641 | } else { | |
| 659 | - | sess, ok := e.localSession() | |
| 660 | - | if !ok { | |
| 642 | + | sess := e.attachment | |
| 643 | + | if sess == nil { | |
| 644 | + | sess = e.sess | |
| 645 | + | } | |
| 646 | + | if sess == nil { | |
| 661 | 647 | return errAttachmentTransition | |
| 662 | 648 | } | |
| 663 | 649 | err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1}) | |
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
| @@ -1083,7 +1083,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re | |||
| 1083 | 1083 | compose: func() { fixture.renderCompositions++ }, | |
| 1084 | 1084 | emit: func() { fixture.renderEmissions++ }, | |
| 1085 | 1085 | } | |
| 1086 | - | d.attachCoordinator(sess, ac, true) | |
| 1086 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 1087 | 1087 | ||
| 1088 | 1088 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| 1089 | 1089 | // actual production diffs rather than the initial full frame. | |
M internal/usecase/daemon/picker.go +28 -31
| @@ -51,12 +51,12 @@ func (d *Daemon) togglePickerSort() { | |||
| 51 | 51 | // enterPicker preserves the existing navigation entry point. Navigation always | |
| 52 | 52 | // publishes its model, including an empty one; only move entry can fail for a | |
| 53 | 53 | // missing destination. | |
| 54 | - | func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) { | |
| 54 | + | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 55 | 55 | model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| 56 | 56 | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |
| 59 | + | func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) { | |
| 60 | 60 | rt := ac.overlays | |
| 61 | 61 | rt.pickerMu.Lock() | |
| 62 | 62 | previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration | |
| @@ -83,10 +83,13 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model | |||
| 83 | 83 | ||
| 84 | 84 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| 85 | 85 | // knows nothing about picker intent; picker.New owns all destination policy. | |
| 86 | - | func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) { | |
| 86 | + | func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) { | |
| 87 | 87 | d.mu.Lock() | |
| 88 | - | sessions := make([]attachmentSession, 0, len(d.sessions)) | |
| 88 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 89 | 89 | for _, entry := range d.sessions { | |
| 90 | + | if entry == nil { | |
| 91 | + | continue | |
| 92 | + | } | |
| 90 | 93 | sessions = append(sessions, entry) | |
| 91 | 94 | } | |
| 92 | 95 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -98,14 +101,14 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke | |||
| 98 | 101 | d.mu.Unlock() | |
| 99 | 102 | ||
| 100 | 103 | for _, entry := range sessions { | |
| 101 | - | if local, ok := localSession(entry); ok { | |
| 102 | - | d.refreshSessionFocusedTitles(local) | |
| 104 | + | if entry != nil { | |
| 105 | + | d.refreshSessionFocusedTitles(entry) | |
| 103 | 106 | } | |
| 104 | 107 | } | |
| 105 | 108 | opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle} | |
| 106 | 109 | ||
| 107 | 110 | type liveSnapshot struct { | |
| 108 | - | entry attachmentSession | |
| 111 | + | entry *session | |
| 109 | 112 | view sessionView | |
| 110 | 113 | } | |
| 111 | 114 | // One snapshot per live session: sorting and view building read the same | |
| @@ -116,13 +119,11 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke | |||
| 116 | 119 | var current picker.SourceFilter | |
| 117 | 120 | for _, entry := range sessions { | |
| 118 | 121 | if entry == cur && ac != nil { | |
| 119 | - | if local, ok := localSession(entry); ok { | |
| 120 | - | local.repairAttachmentView(ac) | |
| 121 | - | } | |
| 122 | + | entry.repairAttachmentView(ac) | |
| 122 | 123 | } | |
| 123 | 124 | snap := entry.snapshotView(opts) | |
| 124 | 125 | if entry == cur { | |
| 125 | - | if _, ok := localSession(entry); ok && ac != nil { | |
| 126 | + | if ac != nil { | |
| 126 | 127 | view := ac.viewSnapshot() | |
| 127 | 128 | if view.tabID != "" { | |
| 128 | 129 | current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID} | |
| @@ -197,7 +198,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke | |||
| 197 | 198 | return views, current | |
| 198 | 199 | } | |
| 199 | 200 | ||
| 200 | - | func (d *Daemon) newPickerModel(cur attachmentSession, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 201 | + | func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model { | |
| 201 | 202 | views, attachedCurrent := d.pickerViews(cur, ac) | |
| 202 | 203 | if intent == pickerNavigate && current == (picker.SourceFilter{}) { | |
| 203 | 204 | current = attachedCurrent | |
| @@ -346,11 +347,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 346 | 347 | var err error | |
| 347 | 348 | if len(effects) != 0 && effects[0] != nil { | |
| 348 | 349 | err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select") | |
| 349 | - | } else if local, ok := localSession(sess); ok { | |
| 350 | - | d.closePicker(ac) | |
| 351 | - | err = d.switchToTarget(local, ac, target) | |
| 352 | 350 | } else { | |
| 353 | - | err = errAttachmentTransition | |
| 351 | + | d.closePicker(ac) | |
| 352 | + | err = d.switchToTarget(sess, ac, target) | |
| 354 | 353 | } | |
| 355 | 354 | if errors.Is(err, errAttachmentTransition) { | |
| 356 | 355 | return | |
| @@ -409,7 +408,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 409 | 408 | if targetSess == ac.currentAttachmentSession() { | |
| 410 | 409 | return | |
| 411 | 410 | } | |
| 412 | - | rc := d.attachCoordinator(targetSess, nil, false) | |
| 411 | + | rc := d.attachCoordinator(targetSess, nil, nil, false) | |
| 413 | 412 | rc.subscribePreviewFor(ac, generation, func(renderWake) { | |
| 414 | 413 | if pickerPreviewCurrent(ac, targetSess, next, generation) { | |
| 415 | 414 | if owner := ac.currentAttachmentSession(); owner != nil { | |
| @@ -479,9 +478,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 479 | 478 | } | |
| 480 | 479 | d.mu.Lock() | |
| 481 | 480 | var clients []*attachedClient | |
| 482 | - | for _, entry := range d.sessions { | |
| 483 | - | sess, ok := localSession(entry) | |
| 484 | - | if !ok { | |
| 481 | + | for _, sess := range d.sessions { | |
| 482 | + | if sess == nil { | |
| 485 | 483 | continue | |
| 486 | 484 | } | |
| 487 | 485 | sess.mu.Lock() | |
| @@ -556,7 +554,7 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool { | |||
| 556 | 554 | return *left == *right | |
| 557 | 555 | } | |
| 558 | 556 | ||
| 559 | - | func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession { | |
| 557 | + | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| 560 | 558 | d.mu.Lock() | |
| 561 | 559 | defer d.mu.Unlock() | |
| 562 | 560 | return d.sessions[id] | |
| @@ -605,9 +603,9 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti | |||
| 605 | 603 | return nil | |
| 606 | 604 | } | |
| 607 | 605 | d.mu.Lock() | |
| 608 | - | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 606 | + | targetSess := d.sessions[target.Session] | |
| 609 | 607 | d.mu.Unlock() | |
| 610 | - | if !ok { | |
| 608 | + | if targetSess == nil { | |
| 611 | 609 | if !token.attachmentCurrent() { | |
| 612 | 610 | return nil | |
| 613 | 611 | } | |
| @@ -667,11 +665,10 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta | |||
| 667 | 665 | } | |
| 668 | 666 | token.effect.bindActionEnd(d, action) | |
| 669 | 667 | token.effect.End() | |
| 670 | - | sess, ok := localSession(token.sess) | |
| 671 | - | if !ok { | |
| 668 | + | if token.sess == nil { | |
| 672 | 669 | return errAttachmentTransition | |
| 673 | 670 | } | |
| 674 | - | return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action) | |
| 671 | + | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | |
| 675 | 672 | } | |
| 676 | 673 | ||
| 677 | 674 | // sendRemoteAttachTargetForAttachment validates the catalog row and hands the | |
| @@ -755,7 +752,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 755 | 752 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | |
| 756 | 753 | } | |
| 757 | 754 | } | |
| 758 | - | } else if active, ok := localSession(d.sessions[target.Session]); ok { | |
| 755 | + | } else if active := d.sessions[target.Session]; active != nil { | |
| 759 | 756 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | |
| 760 | 757 | } | |
| 761 | 758 | d.mu.Unlock() | |
| @@ -1031,9 +1028,9 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac | |||
| 1031 | 1028 | return d.killPickerTarget(target) | |
| 1032 | 1029 | } | |
| 1033 | 1030 | d.mu.Lock() | |
| 1034 | - | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 1031 | + | targetSess := d.sessions[target.Session] | |
| 1035 | 1032 | d.mu.Unlock() | |
| 1036 | - | if !ok { | |
| 1033 | + | if targetSess == nil { | |
| 1037 | 1034 | return nil | |
| 1038 | 1035 | } | |
| 1039 | 1036 | return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| @@ -1048,9 +1045,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error { | |||
| 1048 | 1045 | return nil | |
| 1049 | 1046 | } | |
| 1050 | 1047 | d.mu.Lock() | |
| 1051 | - | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 1048 | + | targetSess := d.sessions[target.Session] | |
| 1052 | 1049 | d.mu.Unlock() | |
| 1053 | - | if ok { | |
| 1050 | + | if targetSess != nil { | |
| 1054 | 1051 | return d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 1055 | 1052 | } | |
| 1056 | 1053 | return nil | |
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi | |||
| 1550 | 1550 | current.mu.Lock() | |
| 1551 | 1551 | current.registerAttachmentLocked(ac) | |
| 1552 | 1552 | current.mu.Unlock() | |
| 1553 | - | require.NotNil(t, d.attachCoordinator(current, ac, true)) | |
| 1553 | + | require.NotNil(t, d.attachCoordinator(current, nil, ac, true)) | |
| 1554 | 1554 | ||
| 1555 | 1555 | d.enterPicker(current, ac) | |
| 1556 | 1556 | d.handlePickerInput(ac, []byte("k")) | |
M internal/usecase/daemon/prompt.go +2 -2
| @@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal { | |||
| 13 | 13 | return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | - | func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string) error) { | |
| 16 | + | func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) { | |
| 17 | 17 | d.closePrompt(ac) | |
| 18 | 18 | ac.overlays.promptMu.Lock() | |
| 19 | 19 | ac.overlays.prompt = promptui.New(title, initial) | |
| @@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title, | |||
| 23 | 23 | d.invalidateRender(sess, ac, true, "prompt.go") | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) { | |
| 26 | + | func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) { | |
| 27 | 27 | d.closePrompt(ac) | |
| 28 | 28 | ac.overlays.promptMu.Lock() | |
| 29 | 29 | ac.overlays.prompt = promptui.New(title, initial) | |
M internal/usecase/daemon/recent_sessions.go +2 -2
| @@ -17,14 +17,14 @@ type recentSession struct { | |||
| 17 | 17 | ||
| 18 | 18 | // recentSessions returns the current session's capped named-session MRU list. | |
| 19 | 19 | // Callers may retain the returned values for the lifetime of an interaction. | |
| 20 | - | func (d *Daemon) recentSessions(current attachmentSession) []recentSession { | |
| 20 | + | func (d *Daemon) recentSessions(current *session) []recentSession { | |
| 21 | 21 | if d == nil { | |
| 22 | 22 | return nil | |
| 23 | 23 | } | |
| 24 | 24 | d.mu.Lock() | |
| 25 | 25 | recent := make([]recentSession, 0, len(d.sessions)) | |
| 26 | 26 | for _, sess := range d.sessions { | |
| 27 | - | if sess == current { | |
| 27 | + | if sess == nil || sess == current { | |
| 28 | 28 | continue | |
| 29 | 29 | } | |
| 30 | 30 | snap := sess.snapshotView(viewOptions{}) | |
M internal/usecase/daemon/render.go +30 -13
| @@ -60,7 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 60 | 60 | return false | |
| 61 | 61 | } | |
| 62 | 62 | d.mu.Lock() | |
| 63 | - | sessions := localSessionsSnapshot(d.sessions) | |
| 63 | + | sessions := sessionsSnapshot(d.sessions) | |
| 64 | 64 | d.mu.Unlock() | |
| 65 | 65 | for _, sess := range sessions { | |
| 66 | 66 | for _, ac := range sess.snapshotAttachments() { | |
| @@ -290,17 +290,26 @@ func (b *runtimeMarkBatch) flush() { | |||
| 290 | 290 | } | |
| 291 | 291 | } | |
| 292 | 292 | ||
| 293 | - | func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 293 | + | type paintResult uint8 | |
| 294 | + | ||
| 295 | + | const ( | |
| 296 | + | paintRejected paintResult = iota | |
| 297 | + | paintEmitted | |
| 298 | + | paintBlockedCapacity | |
| 299 | + | ) | |
| 300 | + | ||
| 301 | + | func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult { | |
| 294 | 302 | // Session-owned PTY preparation remains local; attachment rendering is | |
| 295 | - | // captured through attachmentSession.captureRenderState. | |
| 296 | - | sess, local := localSession(entry) | |
| 303 | + | // captured through session.captureRenderState. | |
| 304 | + | sess := entry | |
| 305 | + | local := sess != nil | |
| 297 | 306 | marks := d.newRuntimeMarkBatch() | |
| 298 | 307 | if lease != nil { | |
| 299 | 308 | token := attachmentToken(entry, ac, ac.transport()) | |
| 300 | 309 | token.lease = lease | |
| 301 | 310 | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 302 | 311 | if !admitted { | |
| 303 | - | return | |
| 312 | + | return paintRejected | |
| 304 | 313 | } | |
| 305 | 314 | marks.attachmentEffect = ticket | |
| 306 | 315 | defer ticket.End() | |
| @@ -309,7 +318,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 309 | 318 | if local { | |
| 310 | 319 | tb = sess.tabForAttachment(ac) | |
| 311 | 320 | if tb == nil { | |
| 312 | - | return | |
| 321 | + | return paintRejected | |
| 313 | 322 | } | |
| 314 | 323 | } | |
| 315 | 324 | ||
| @@ -322,23 +331,30 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 322 | 331 | entry.core().mu.Unlock() | |
| 323 | 332 | if !owned || ac.currentAttachmentSession() != entry { | |
| 324 | 333 | ac.sendMu.Unlock() | |
| 325 | - | return | |
| 334 | + | return paintRejected | |
| 326 | 335 | } | |
| 327 | 336 | if lease != nil { | |
| 328 | 337 | rc := attachmentRenderCoordinator(entry) | |
| 329 | 338 | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | |
| 330 | 339 | ac.sendMu.Unlock() | |
| 331 | - | return | |
| 340 | + | return paintRejected | |
| 332 | 341 | } | |
| 333 | 342 | } | |
| 334 | - | // Capacity is checked before any destructive capture. The coordinator is | |
| 335 | - | // the normal gate, but this ownership check also protects direct resize and | |
| 336 | - | // test paint paths from consuming damage that cannot be emitted. | |
| 343 | + | // Capacity is checked before any destructive capture. Refresh the atomic | |
| 344 | + | // readiness snapshot while sendMu is held so direct test/setup mutations of | |
| 345 | + | // the stream fields remain covered without making coordinator probes wait on | |
| 346 | + | // a blocked transport send. | |
| 347 | + | if ac.output != nil { | |
| 348 | + | ac.output.syncCapacityLocked() | |
| 349 | + | } | |
| 350 | + | // The coordinator is the normal gate, but this ownership check also protects | |
| 351 | + | // direct resize and test paint paths from consuming damage that cannot be | |
| 352 | + | // emitted. | |
| 337 | 353 | if ac.output != nil && ac.output.atCapacity() { | |
| 338 | 354 | // The coordinator owns the blocked interval; this guard only protects | |
| 339 | 355 | // direct test/resize paint calls from destructive capture. | |
| 340 | 356 | ac.sendMu.Unlock() | |
| 341 | - | return | |
| 357 | + | return paintBlockedCapacity | |
| 342 | 358 | } | |
| 343 | 359 | // Composition owns attachment sendMu; initialize its lazy overlay state | |
| 344 | 360 | // under that same ownership so concurrent fallback paints cannot observe a | |
| @@ -418,7 +434,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 418 | 434 | endCapture(0, ok) | |
| 419 | 435 | if !ok { | |
| 420 | 436 | ac.sendMu.Unlock() | |
| 421 | - | return | |
| 437 | + | return paintRejected | |
| 422 | 438 | } | |
| 423 | 439 | if ac.renderStages.capture != nil { | |
| 424 | 440 | ac.renderStages.capture() | |
| @@ -437,6 +453,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, | |||
| 437 | 453 | ac.renderStages.compose() | |
| 438 | 454 | } | |
| 439 | 455 | d.emitFrame(entry, ac, state, composed, &marks) | |
| 456 | + | return paintEmitted | |
| 440 | 457 | } | |
| 441 | 458 | ||
| 442 | 459 | var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
M internal/usecase/daemon/render_capture.go +1 -1
| @@ -344,7 +344,7 @@ func captureLocalRenderState( | |||
| 344 | 344 | } | |
| 345 | 345 | ||
| 346 | 346 | // captureRenderState preserves the focused local test seam. Production | |
| 347 | - | // rendering delegates through attachmentSession.captureRenderState. | |
| 347 | + | // rendering delegates through *session.captureRenderState. | |
| 348 | 348 | func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |
| 349 | 349 | return captureLocalRenderState(sess, ac, request) | |
| 350 | 350 | } | |
M internal/usecase/daemon/render_coordinator.go +86 -9
| @@ -39,9 +39,6 @@ type renderCoordinator struct { | |||
| 39 | 39 | // invalidations retain only their exact target lease here. | |
| 40 | 40 | pendingShared bool | |
| 41 | 41 | pendingTargets map[*attachedClient]*attachmentLease | |
| 42 | - | // ackReadyFor is production's attachment-scoped ACK-window probe. The | |
| 43 | - | // legacy options callback remains for coordinator-only tests. | |
| 44 | - | ackReadyFor func(*attachedClient) bool | |
| 45 | 42 | // ackBlocked retains the first rejected ACK-capacity probe until the | |
| 46 | 43 | // matching consume or lifecycle closure releases that exact interval. | |
| 47 | 44 | ackBlocked *ackBlockedSpan | |
| @@ -316,6 +313,81 @@ func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) { | |||
| 316 | 313 | c.fire(gen, false, false) | |
| 317 | 314 | } | |
| 318 | 315 | ||
| 316 | + | // retryCapacity restores only the attachment whose wake failed the final | |
| 317 | + | // capacity check. Readiness is an atomic observation, so paint may discover | |
| 318 | + | // backpressure after fire has marked the wake delivered. Requeueing the exact | |
| 319 | + | // wake generation keeps that stale result from clearing shared work and avoids | |
| 320 | + | // making a healthy peer wait for the blocked attachment. | |
| 321 | + | func (c *renderCoordinator) retryCapacity(w renderWake, lease *attachmentLease) { | |
| 322 | + | if lease == nil || w.generation == 0 { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | var queueStart bool | |
| 326 | + | c.mu.Lock() | |
| 327 | + | if !c.leaseCurrentLocked(lease, true) || c.pendingGeneration < w.generation { | |
| 328 | + | c.mu.Unlock() | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | // A newer wake already attempted this lease; its callback owns any | |
| 332 | + | // capacity retry. An older callback may still widen a newer targeted wake | |
| 333 | + | // to include this lease when that newer wake never reached it. | |
| 334 | + | if c.pendingGeneration > w.generation && lease.deliveredGeneration == c.pendingGeneration { | |
| 335 | + | c.mu.Unlock() | |
| 336 | + | return | |
| 337 | + | } | |
| 338 | + | if !c.pending { | |
| 339 | + | c.pending = true | |
| 340 | + | c.pendingReset = w.reset | |
| 341 | + | c.pendingUrgent = w.urgent | |
| 342 | + | c.pendingShared = false | |
| 343 | + | c.pendingTargets = map[*attachedClient]*attachmentLease{lease.attachment: lease} | |
| 344 | + | c.coalesced = max(1, w.coalesced) | |
| 345 | + | c.deadlineDue = false | |
| 346 | + | c.armed = false | |
| 347 | + | if c.opts.observer != nil && !c.queueMarked { | |
| 348 | + | c.queueMarked = true | |
| 349 | + | c.queueCorrelation = ports.NewRuntimeCorrelation() | |
| 350 | + | queueStart = true | |
| 351 | + | } | |
| 352 | + | } else { | |
| 353 | + | c.pendingReset = c.pendingReset || w.reset | |
| 354 | + | c.pendingUrgent = c.pendingUrgent || w.urgent | |
| 355 | + | if !c.pendingShared { | |
| 356 | + | if c.pendingTargets == nil { | |
| 357 | + | c.pendingTargets = make(map[*attachedClient]*attachmentLease) | |
| 358 | + | } | |
| 359 | + | c.pendingTargets[lease.attachment] = lease | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | if c.delivered != nil { | |
| 363 | + | delete(c.delivered, lease.attachment) | |
| 364 | + | } | |
| 365 | + | if c.ackDeferredFor == nil { | |
| 366 | + | c.ackDeferredFor = make(map[*attachedClient]bool) | |
| 367 | + | } | |
| 368 | + | c.ackDeferredFor[lease.attachment] = true | |
| 369 | + | c.ackDeferred = true | |
| 370 | + | generation := c.pendingGeneration | |
| 371 | + | queueCorrelation := c.queueCorrelation | |
| 372 | + | observer := c.opts.observer | |
| 373 | + | c.supervisor.startTaskLocked(func() { c.retryPendingGeneration(generation) }) | |
| 374 | + | c.mu.Unlock() | |
| 375 | + | if queueStart && observer != nil { | |
| 376 | + | observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("daemon", queueCorrelation, ports.RuntimeQueueEnqueued, 0, true)) | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func (c *renderCoordinator) retryPendingGeneration(generation uint64) { | |
| 381 | + | c.mu.Lock() | |
| 382 | + | if c.torndown || !c.pending || c.pendingGeneration != generation { | |
| 383 | + | c.mu.Unlock() | |
| 384 | + | return | |
| 385 | + | } | |
| 386 | + | gen, token := c.normalLane.generation, c.normalLane.token | |
| 387 | + | c.mu.Unlock() | |
| 388 | + | c.fireWithTimerToken(token, gen, false, false) | |
| 389 | + | } | |
| 390 | + | ||
| 319 | 391 | // noteSyncBegin records a synchronized-output batch for its stable pane and | |
| 320 | 392 | // arms that pane's watchdog. Overlapping pane batches are independent. | |
| 321 | 393 | // markDeadlineDue records a received timer tick before the worker probes ACK | |
| @@ -807,7 +879,7 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||
| 807 | 879 | // invalidateRender is the sole producer fan-in. In tests and transitional | |
| 808 | 880 | // headless paths without an attached coordinator it retains the old private | |
| 809 | 881 | // compositor; attached sessions always schedule through their coordinator. | |
| 810 | - | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 882 | + | func (d *Daemon) invalidateRender(entry *session, ac *attachedClient, reset bool, producer string) { | |
| 811 | 883 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 812 | 884 | invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer} | |
| 813 | 885 | if rc.invalidateForAttachment(ac, invalidation) { | |
| @@ -832,7 +904,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r | |||
| 832 | 904 | // invalidateRenderNow publishes through the coordinator but immediately | |
| 833 | 905 | // flushes the wake when the client can accept state. Attach uses this path so | |
| 834 | 906 | // the required first full frame never depends on a debounce timer. | |
| 835 | - | func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 907 | + | func (d *Daemon) invalidateRenderNow(entry *session, ac *attachedClient, reset bool, producer string) { | |
| 836 | 908 | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 837 | 909 | if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) { | |
| 838 | 910 | rc.fireCurrent(false) | |
| @@ -880,8 +952,8 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa | |||
| 880 | 952 | } | |
| 881 | 953 | ||
| 882 | 954 | func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool { | |
| 883 | - | if c.ackReadyFor != nil { | |
| 884 | - | return c.ackReadyFor(ac) | |
| 955 | + | if c.opts.ackReadyFor != nil { | |
| 956 | + | return c.opts.ackReadyFor(ac) | |
| 885 | 957 | } | |
| 886 | 958 | if c.opts.ackReady != nil { | |
| 887 | 959 | return c.opts.ackReady() | |
| @@ -1013,7 +1085,11 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui | |||
| 1013 | 1085 | if len(leases) == 0 && legacyOutput { | |
| 1014 | 1086 | allDelivered = true | |
| 1015 | 1087 | } | |
| 1016 | - | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, attachmentLeases: attachmentLeases, watchdog: watchdog} | |
| 1088 | + | wakeGeneration := uint64(0) | |
| 1089 | + | if len(leases) != 0 { | |
| 1090 | + | wakeGeneration = c.pendingGeneration | |
| 1091 | + | } | |
| 1092 | + | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, generation: wakeGeneration, attachmentLeases: attachmentLeases, watchdog: watchdog} | |
| 1017 | 1093 | if syncLease != nil { | |
| 1018 | 1094 | if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease { | |
| 1019 | 1095 | w.lease = syncLease | |
| @@ -1043,11 +1119,12 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui | |||
| 1043 | 1119 | } | |
| 1044 | 1120 | ||
| 1045 | 1121 | if outputReady { | |
| 1046 | - | for ac := range attachmentLeases { | |
| 1122 | + | for ac, lease := range attachmentLeases { | |
| 1047 | 1123 | if c.delivered == nil { | |
| 1048 | 1124 | c.delivered = make(map[*attachedClient]uint64) | |
| 1049 | 1125 | } | |
| 1050 | 1126 | c.delivered[ac] = c.pendingGeneration | |
| 1127 | + | lease.deliveredGeneration = c.pendingGeneration | |
| 1051 | 1128 | } | |
| 1052 | 1129 | } | |
| 1053 | 1130 | if len(leases) != 0 { | |
M internal/usecase/daemon/render_coordinator_support.go +2 -2
| @@ -15,7 +15,7 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics | |||
| 15 | 15 | ||
| 16 | 16 | // attachmentRenderCoordinator returns the coordinator installed for the exact | |
| 17 | 17 | // attachment-session identity. | |
| 18 | - | func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator { | |
| 18 | + | func attachmentRenderCoordinator(entry *session) *renderCoordinator { | |
| 19 | 19 | if entry == nil || entry.core() == nil { | |
| 20 | 20 | return nil | |
| 21 | 21 | } | |
| @@ -24,7 +24,7 @@ func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator { | |||
| 24 | 24 | ||
| 25 | 25 | // installAttachmentRenderCoordinator publishes rc for the exact | |
| 26 | 26 | // attachment-session identity. | |
| 27 | - | func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) { | |
| 27 | + | func installAttachmentRenderCoordinator(entry *session, rc *renderCoordinator) { | |
| 28 | 28 | if entry == nil || entry.core() == nil { | |
| 29 | 29 | return | |
| 30 | 30 | } | |
M internal/usecase/daemon/render_coordinator_test.go +4 -4
| @@ -195,6 +195,7 @@ func awaitWake(t *testing.T, ch chan renderWake) renderWake { | |||
| 195 | 195 | t.Helper() | |
| 196 | 196 | wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement") | |
| 197 | 197 | wake.attachmentLeases = nil | |
| 198 | + | wake.generation = 0 | |
| 198 | 199 | return wake | |
| 199 | 200 | } | |
| 200 | 201 | ||
| @@ -404,12 +405,11 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) { | |||
| 404 | 405 | func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) { | |
| 405 | 406 | slow, healthy := &attachedClient{}, &attachedClient{} | |
| 406 | 407 | var slowReady atomic.Bool | |
| 407 | - | slowReady.Store(false) | |
| 408 | 408 | wakes := make(chan renderWake, 2) | |
| 409 | 409 | rc := newRenderCoordinator(renderCoordinatorOptions{ | |
| 410 | - | wake: func(w renderWake) { wakes <- w }, | |
| 410 | + | ackReadyFor: func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() }, | |
| 411 | + | wake: func(w renderWake) { wakes <- w }, | |
| 411 | 412 | }) | |
| 412 | - | rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() } | |
| 413 | 413 | rc.attach(slow) | |
| 414 | 414 | rc.attach(healthy) | |
| 415 | 415 | rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |
| @@ -1403,7 +1403,7 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T | |||
| 1403 | 1403 | } | |
| 1404 | 1404 | ||
| 1405 | 1405 | func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) { | |
| 1406 | - | t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) { | |
| 1406 | + | t.Run("detach retains a gated headless preview until complete", func(t *testing.T) { | |
| 1407 | 1407 | for _, transition := range []struct { | |
| 1408 | 1408 | name string | |
| 1409 | 1409 | run func(*renderCoordinator, *attachedClient) | |
M internal/usecase/daemon/render_coordinator_types.go +14 -4
| @@ -51,6 +51,9 @@ type renderWake struct { | |||
| 51 | 51 | reset bool | |
| 52 | 52 | urgent bool | |
| 53 | 53 | coalesced int | |
| 54 | + | // generation fences a capacity retry to the mutation that produced this | |
| 55 | + | // wake. A newer invalidation owns its own retry path. | |
| 56 | + | generation uint64 | |
| 54 | 57 | // lease is optional coordinator-owned lifecycle safety for an attachment | |
| 55 | 58 | // specific wake. Shared wakes leave it nil and snapshot live attachment | |
| 56 | 59 | // leases at dispatch time; each paint then revalidates its exact connection | |
| @@ -68,8 +71,12 @@ type attachmentLease struct { | |||
| 68 | 71 | attachment *attachedClient | |
| 69 | 72 | ready bool | |
| 70 | 73 | active bool | |
| 71 | - | // A lease is an immutable callback capability. Its lifecycle bits are | |
| 72 | - | // changed only under renderCoordinator.mu; callbacks revalidate it at each | |
| 74 | + | // deliveredGeneration records the latest wake that reached this lease. It | |
| 75 | + | // outlives the coordinator's coalesced delivered map so an older capacity | |
| 76 | + | // failure cannot overwrite a newer attempt. | |
| 77 | + | deliveredGeneration uint64 | |
| 78 | + | // A lease is an immutable callback capability. Its lifecycle and delivery | |
| 79 | + | // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each | |
| 73 | 80 | // short effect boundary and never retain coordinator ownership while routing | |
| 74 | 81 | // arbitrary handlers. | |
| 75 | 82 | } | |
| @@ -95,8 +102,11 @@ type renderCoordinatorOptions struct { | |||
| 95 | 102 | // wake composes a coalesced render request. | |
| 96 | 103 | wake func(renderWake) | |
| 97 | 104 | // ackReady reports whether the attachment may compose another output | |
| 98 | - | // state (the outputStateStream window has capacity). | |
| 99 | - | ackReady func() bool | |
| 105 | + | // state (the outputStateStream window has capacity). It is retained for | |
| 106 | + | // coordinator-only tests and headless callers; production uses ackReadyFor | |
| 107 | + | // so one attachment cannot gate another. | |
| 108 | + | ackReady func() bool | |
| 109 | + | ackReadyFor func(*attachedClient) bool | |
| 100 | 110 | // syncRenderable reports whether a pane's synchronized batch currently | |
| 101 | 111 | // contributes to the attached composition. It must not acquire c.mu. | |
| 102 | 112 | syncRenderable func(*pane) bool | |
M internal/usecase/daemon/render_damage_receipt_test.go +2 -2
| @@ -134,7 +134,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 134 | 134 | // Prepare failures now schedule recovery through the coordinator; | |
| 135 | 135 | // retain the failed transaction here until this test explicitly retries. | |
| 136 | 136 | d.clock = newNoticeClock() | |
| 137 | - | d.attachCoordinator(sess, ac, true) | |
| 137 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 138 | 138 | healthy := ac.transport() | |
| 139 | 139 | p := sess.tabs[0].focusedPane() | |
| 140 | 140 | ||
| @@ -170,7 +170,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T | |||
| 170 | 170 | func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) { | |
| 171 | 171 | clock := newNoticeClock() | |
| 172 | 172 | d, sess, ac, _ := newNoticeFixture(t, clock) | |
| 173 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 173 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 174 | 174 | var producers []string | |
| 175 | 175 | rc.opts.onInvalidate = func(inv renderInvalidation) { | |
| 176 | 176 | // Both the notice repaint and the explicit recovery must re-enter only | |
M internal/usecase/daemon/render_pipeline.go +8 -12
| @@ -428,7 +428,7 @@ func commitDamageReceipts(receipts []damageReceipt) { | |||
| 428 | 428 | ||
| 429 | 429 | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | |
| 430 | 430 | // sendMu for the complete capture/compose/emit transaction. | |
| 431 | - | func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | |
| 431 | + | func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | |
| 432 | 432 | var ownedMarks runtimeMarkBatch | |
| 433 | 433 | var marks *runtimeMarkBatch | |
| 434 | 434 | if len(batches) != 0 { | |
| @@ -480,17 +480,15 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 480 | 480 | // Without a coordinator reportError repaints synchronously. Suppress only | |
| 481 | 481 | // that nested notice repaint; leave the guard before returning so a later, | |
| 482 | 482 | // independent failed transaction can still notify the user. | |
| 483 | - | if sess, ok := localSession(entry); ok { | |
| 484 | - | if sess.renderCoordinator() == nil { | |
| 485 | - | if !ac.prepareFailureFallback.CompareAndSwap(false, true) { | |
| 486 | - | return true | |
| 487 | - | } | |
| 488 | - | d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |
| 489 | - | ac.prepareFailureFallback.Store(false) | |
| 483 | + | if entry.renderCoordinator() == nil { | |
| 484 | + | if !ac.prepareFailureFallback.CompareAndSwap(false, true) { | |
| 490 | 485 | return true | |
| 491 | 486 | } | |
| 492 | - | d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |
| 487 | + | d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |
| 488 | + | ac.prepareFailureFallback.Store(false) | |
| 489 | + | return true | |
| 493 | 490 | } | |
| 491 | + | d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err)) | |
| 494 | 492 | // Notices are session-scoped, while the attachment repaint still needs | |
| 495 | 493 | // to redraw its chrome after a failed transaction. | |
| 496 | 494 | d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed") | |
| @@ -563,9 +561,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 563 | 561 | // admission first. Detachment freezes the gate and therefore cannot mutate | |
| 564 | 562 | // ownership until any enclosing admitted operation has also ended. | |
| 565 | 563 | if marks.attachmentEffect == nil { | |
| 566 | - | if sess, ok := localSession(entry); ok { | |
| 567 | - | d.detachOnSendError(sess, ac, sendTr) | |
| 568 | - | } | |
| 564 | + | d.detachOnSendError(entry, ac, sendTr) | |
| 569 | 565 | } else { | |
| 570 | 566 | // Capture the exact admitted capability, including its coordinator | |
| 571 | 567 | // lease, before End permits a new attachment publication. Reserve | |
M internal/usecase/daemon/render_test.go +5 -5
| @@ -313,7 +313,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 313 | 313 | d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY) | |
| 314 | 314 | clock := newCoordinatorMockClock(t, 8) | |
| 315 | 315 | d.clock = clock.clock | |
| 316 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 316 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 317 | 317 | ||
| 318 | 318 | d.sessWg.Add(1) | |
| 319 | 319 | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |
| @@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 349 | 349 | d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY) | |
| 350 | 350 | clock := newCoordinatorMockClock(t, 8) | |
| 351 | 351 | d.clock = clock.clock | |
| 352 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 352 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 353 | 353 | ||
| 354 | 354 | d.sessWg.Add(2) | |
| 355 | 355 | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |
| @@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 385 | 385 | d, target, targetClient, _ := newManualSessionWithPTYs(t, pty) | |
| 386 | 386 | clock := newCoordinatorMockClock(t, 8) | |
| 387 | 387 | d.clock = clock.clock | |
| 388 | - | rc := d.attachCoordinator(target, targetClient, true) | |
| 388 | + | rc := d.attachCoordinator(target, nil, targetClient, true) | |
| 389 | 389 | ||
| 390 | 390 | // A viewer in a different session is previewing the headless target. | |
| 391 | 391 | viewer := &attachedClient{} | |
| @@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 429 | 429 | d, target, detached, _ := newManualSessionWithPTYs(t, pty) | |
| 430 | 430 | clock := newCoordinatorMockClock(t, 8) | |
| 431 | 431 | d.clock = clock.clock | |
| 432 | - | rc := d.attachCoordinator(target, detached, true) | |
| 432 | + | rc := d.attachCoordinator(target, nil, detached, true) | |
| 433 | 433 | ||
| 434 | 434 | secondTransport, secondSends := newCapturingTransport(t) | |
| 435 | 435 | remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size} | |
| @@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 470 | 470 | d, target, client, _ := newManualSessionWithPTYs(t, pty) | |
| 471 | 471 | clock := newCoordinatorMockClock(t, 8) | |
| 472 | 472 | d.clock = clock.clock | |
| 473 | - | rc := d.attachCoordinator(target, client, true) | |
| 473 | + | rc := d.attachCoordinator(target, nil, client, true) | |
| 474 | 474 | wakes := make(chan renderWake, 1) | |
| 475 | 475 | rc.opts.wake = func(w renderWake) { wakes <- w } | |
| 476 | 476 | target.mu.Lock() | |
M internal/usecase/daemon/render_timer.go +12 -3
| @@ -66,15 +66,24 @@ func (l *timerLane) clearLocked(token *timerToken) bool { | |||
| 66 | 66 | type timerSupervisor struct{ workers sync.WaitGroup } | |
| 67 | 67 | ||
| 68 | 68 | func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) { | |
| 69 | - | s.workers.Add(1) | |
| 70 | - | go func() { | |
| 71 | - | defer s.workers.Done() | |
| 69 | + | s.startTaskLocked(func() { | |
| 72 | 70 | defer close(token.done) | |
| 73 | 71 | select { | |
| 74 | 72 | case <-timerC: | |
| 75 | 73 | fire() | |
| 76 | 74 | case <-token.cancel: | |
| 77 | 75 | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // startTaskLocked accounts for coordinator work that is not backed by a timer | |
| 80 | + | // token. The caller must hold the coordinator lock so terminal teardown cannot | |
| 81 | + | // begin waiting between registration and launch. | |
| 82 | + | func (s *timerSupervisor) startTaskLocked(task func()) { | |
| 83 | + | s.workers.Add(1) | |
| 84 | + | go func() { | |
| 85 | + | defer s.workers.Done() | |
| 86 | + | task() | |
| 78 | 87 | }() | |
| 79 | 88 | } | |
| 80 | 89 | ||
M internal/usecase/daemon/resume.go +3 -6
| @@ -316,12 +316,9 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool { | |||
| 316 | 316 | d.mu.Lock() | |
| 317 | 317 | defer d.mu.Unlock() | |
| 318 | 318 | token := ac.resumeClaimToken | |
| 319 | - | if token == 0 { | |
| 320 | - | return true | |
| 321 | - | } | |
| 322 | 319 | parked := d.parked[token] | |
| 323 | - | if parked == nil || parked.ac != ac || !parked.claimed { | |
| 324 | - | return false | |
| 320 | + | if token == 0 || parked == nil || parked.ac != ac || !parked.claimed { | |
| 321 | + | return token == 0 | |
| 325 | 322 | } | |
| 326 | 323 | delete(d.parked, token) | |
| 327 | 324 | parked.claimed = false | |
| @@ -612,7 +609,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 612 | 609 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 613 | 610 | ac.rebaseOutput() | |
| 614 | 611 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 615 | - | ac.output.syncCapacityLocked() | |
| 612 | + | ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding) | |
| 616 | 613 | ac.replaceTransport(tr) | |
| 617 | 614 | ac.size = sz | |
| 618 | 615 | ac.resumeToken = d.nextResumeTokenLocked() | |
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) | |
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
| @@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) { | |||
| 69 | 69 | ||
| 70 | 70 | func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) { | |
| 71 | 71 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 72 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 72 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 73 | 73 | oldLease := rc.attachmentLease(ac) | |
| 74 | 74 | require.NotNil(t, oldLease) | |
| 75 | 75 | ||
M internal/usecase/daemon/session.go +18 -27
| @@ -155,7 +155,7 @@ type tab struct { | |||
| 155 | 155 | // what it has actually seen). sendMu serialises the two senders — the render | |
| 156 | 156 | // coordinator and the connection handler — so the transport's single-writer | |
| 157 | 157 | ||
| 158 | - | func (d *Daemon) touchMRU(entry attachmentSession) { | |
| 158 | + | func (d *Daemon) touchMRU(entry *session) { | |
| 159 | 159 | if d == nil || entry == nil || entry.core() == nil { | |
| 160 | 160 | return | |
| 161 | 161 | } | |
| @@ -170,15 +170,11 @@ func (d *Daemon) touchMRU(entry attachmentSession) { | |||
| 170 | 170 | break | |
| 171 | 171 | } | |
| 172 | 172 | } | |
| 173 | - | sess, ok := localSession(entry) | |
| 174 | - | if !ok { | |
| 175 | - | return | |
| 176 | - | } | |
| 177 | - | sess.mu.Lock() | |
| 178 | - | if !sess.ephemeral { | |
| 179 | - | d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate()) | |
| 173 | + | entry.mu.Lock() | |
| 174 | + | if !entry.ephemeral { | |
| 175 | + | d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate()) | |
| 180 | 176 | } | |
| 181 | - | sess.mu.Unlock() | |
| 177 | + | entry.mu.Unlock() | |
| 182 | 178 | } | |
| 183 | 179 | ||
| 184 | 180 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | |
| @@ -474,8 +470,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 474 | 470 | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | |
| 475 | 471 | copySourceEnvironment: true, ready: true, | |
| 476 | 472 | createTargetLocked: func() (*session, error) { | |
| 477 | - | source, ok := localSession(token.sess) | |
| 478 | - | if !ok { | |
| 473 | + | source := token.sess | |
| 474 | + | if source == nil { | |
| 479 | 475 | return nil, errAttachmentTransition | |
| 480 | 476 | } | |
| 481 | 477 | if d.closing { | |
| @@ -500,8 +496,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 500 | 496 | } | |
| 501 | 497 | ||
| 502 | 498 | d.touchMRU(created) | |
| 503 | - | if source, ok := localSession(token.sess); ok { | |
| 504 | - | token.ac.recordPreviousSession(source) | |
| 499 | + | if token.sess != nil { | |
| 500 | + | token.ac.recordPreviousSession(token.sess) | |
| 505 | 501 | } | |
| 506 | 502 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | |
| 507 | 503 | d.deferAttachmentTransitionCleanups(transition) | |
| @@ -513,7 +509,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 513 | 509 | return d.createTabForAttachment(sess, nil, sz) | |
| 514 | 510 | } | |
| 515 | 511 | ||
| 516 | - | func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz domain.Size) error { | |
| 512 | + | func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ domain.Size) error { | |
| 517 | 513 | sess.mu.Lock() | |
| 518 | 514 | name := sess.name | |
| 519 | 515 | cwd := sess.cwd | |
| @@ -521,7 +517,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do | |||
| 521 | 517 | env := copyEnvironment(sess.env) | |
| 522 | 518 | attachments := sess.snapshotAttachmentsLocked() | |
| 523 | 519 | sess.mu.Unlock() | |
| 524 | - | tbSize := contentSize(sz) | |
| 520 | + | tbSize := contentSize(sess.fullViewportSize()) | |
| 525 | 521 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 526 | 522 | if err != nil { | |
| 527 | 523 | return err | |
| @@ -774,10 +770,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, | |||
| 774 | 770 | // own an independent connection lifecycle and detach through the same exact | |
| 775 | 771 | // transport/generation fence. | |
| 776 | 772 | if token.lease == nil { | |
| 777 | - | if sess, ok := localSession(token.sess); ok { | |
| 778 | - | return d.detachIfCurrentTransport(sess, token.ac, token.transport) | |
| 779 | - | } | |
| 780 | - | return false | |
| 773 | + | return d.detachIfCurrentTransport(token.sess, token.ac, token.transport) | |
| 781 | 774 | } | |
| 782 | 775 | frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac) | |
| 783 | 776 | defer frozen.unfreeze() | |
| @@ -1152,9 +1145,8 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1152 | 1145 | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 1153 | 1146 | return sessionKillParticipants{}, false | |
| 1154 | 1147 | } | |
| 1155 | - | var ok bool | |
| 1156 | - | snapshot.source, ok = localSession(token.sess) | |
| 1157 | - | if !ok { | |
| 1148 | + | snapshot.source = token.sess | |
| 1149 | + | if snapshot.source == nil { | |
| 1158 | 1150 | return sessionKillParticipants{}, false | |
| 1159 | 1151 | } | |
| 1160 | 1152 | snapshot.sourceToken = &admission.token | |
| @@ -1641,8 +1633,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1641 | 1633 | // d.mu. | |
| 1642 | 1634 | func (d *Daemon) allocEphemeralNameLocked() string { | |
| 1643 | 1635 | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | |
| 1644 | - | for _, entry := range d.sessions { | |
| 1645 | - | if s, ok := localSession(entry); ok { | |
| 1636 | + | for _, s := range d.sessions { | |
| 1637 | + | if s != nil { | |
| 1646 | 1638 | used[s.name] = struct{}{} | |
| 1647 | 1639 | } | |
| 1648 | 1640 | } | |
| @@ -1658,9 +1650,8 @@ func (d *Daemon) allocEphemeralNameLocked() string { | |||
| 1658 | 1650 | } | |
| 1659 | 1651 | ||
| 1660 | 1652 | func (d *Daemon) findByNameLocked(name string) *session { | |
| 1661 | - | for _, entry := range d.sessions { | |
| 1662 | - | s, ok := localSession(entry) | |
| 1663 | - | if ok && s.name == name { | |
| 1653 | + | for _, s := range d.sessions { | |
| 1654 | + | if s != nil && s.name == name { | |
| 1664 | 1655 | return s | |
| 1665 | 1656 | } | |
| 1666 | 1657 | } | |
M internal/usecase/daemon/session_back.go +3 -3
| @@ -45,11 +45,11 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 45 | 45 | ||
| 46 | 46 | // clearPreviousSessionIf clears target only if it has not been replaced since | |
| 47 | 47 | // the caller observed it. This preserves a concurrent successful hand-off. | |
| 48 | - | func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) { | |
| 48 | + | func (ac *attachedClient) clearPreviousSessionIf(target *session) { | |
| 49 | 49 | if ac == nil { | |
| 50 | 50 | return | |
| 51 | 51 | } | |
| 52 | - | ac.previousSession.With(func(previous *attachmentSession) { | |
| 52 | + | ac.previousSession.With(func(previous **session) { | |
| 53 | 53 | if *previous == target { | |
| 54 | 54 | *previous = nil | |
| 55 | 55 | } | |
| @@ -59,7 +59,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) { | |||
| 59 | 59 | // recordPreviousSession is called only after a cross-session hand-off has | |
| 60 | 60 | // committed. Keeping this after the commit makes failed/displaced switches | |
| 61 | 61 | // leave the toggle pair intact. | |
| 62 | - | func (ac *attachedClient) recordPreviousSession(origin attachmentSession) { | |
| 62 | + | func (ac *attachedClient) recordPreviousSession(origin *session) { | |
| 63 | 63 | if ac != nil && origin != nil { | |
| 64 | 64 | ac.previousSession.Set(origin) | |
| 65 | 65 | } | |
M internal/usecase/daemon/session_core.go +6 -23
| @@ -7,16 +7,6 @@ import ( | |||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | // attachmentSession is the private attachment-facing session contract. Local | |
| 11 | - | // process, tab, persistence, and teardown behavior remains on *session. | |
| 12 | - | type attachmentSession interface { | |
| 13 | - | core() *sessionCore | |
| 14 | - | snapshotView(viewOptions) sessionView | |
| 15 | - | statusSegments(includeTerminalTitle bool) statusSnapshot | |
| 16 | - | captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) | |
| 17 | - | validTargetTabLocked(tabIndex int) bool | |
| 18 | - | } | |
| 19 | - | ||
| 20 | 10 | // sessionCore is the state shared by every attachment-capable session. It is | |
| 21 | 11 | // embedded as the first field of session so the existing s.mu selector remains | |
| 22 | 12 | // the one lock guarding both attachment membership and local tab state. | |
| @@ -60,19 +50,12 @@ func (s *session) validTargetTabLocked(tabIndex int) bool { | |||
| 60 | 50 | return tabIndex < len(s.tabs) | |
| 61 | 51 | } | |
| 62 | 52 | ||
| 63 | - | // localSession narrows an attachment entry for operations that own local PTYs, | |
| 64 | - | // tabs, snapshots, or durable lifecycle state. | |
| 65 | - | func localSession(entry attachmentSession) (*session, bool) { | |
| 66 | - | sess, ok := entry.(*session) | |
| 67 | - | return sess, ok && sess != nil | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session { | |
| 71 | - | locals := make([]*session, 0, len(entries)) | |
| 72 | - | for _, entry := range entries { | |
| 73 | - | if sess, ok := localSession(entry); ok { | |
| 74 | - | locals = append(locals, sess) | |
| 53 | + | func sessionsSnapshot(entries map[domain.SessionID]*session) []*session { | |
| 54 | + | sessions := make([]*session, 0, len(entries)) | |
| 55 | + | for _, sess := range entries { | |
| 56 | + | if sess != nil { | |
| 57 | + | sessions = append(sessions, sess) | |
| 75 | 58 | } | |
| 76 | 59 | } | |
| 77 | - | return locals | |
| 60 | + | return sessions | |
| 78 | 61 | } | |
M internal/usecase/daemon/session_core_test.go +4 -6
| @@ -30,7 +30,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||
| 30 | 30 | ||
| 31 | 31 | core := sess.core() | |
| 32 | 32 | require.Same(t, &sess.sessionCore, core) | |
| 33 | - | require.Same(t, sess, mustLocalSession(t, attachmentSession(sess))) | |
| 34 | 33 | require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities") | |
| 35 | 34 | ||
| 36 | 35 | core.mu.Lock() | |
| @@ -113,7 +112,7 @@ func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) { | |||
| 113 | 112 | } | |
| 114 | 113 | ||
| 115 | 114 | func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) { | |
| 116 | - | d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)} | |
| 115 | + | d := &Daemon{sessions: make(map[domain.SessionID]*session)} | |
| 117 | 116 | first := &session{} | |
| 118 | 117 | first.id = domain.SessionID("same") | |
| 119 | 118 | second := &session{} | |
| @@ -146,9 +145,8 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) { | |||
| 146 | 145 | second.core().mu.Unlock() | |
| 147 | 146 | } | |
| 148 | 147 | ||
| 149 | - | func mustLocalSession(t *testing.T, entry attachmentSession) *session { | |
| 148 | + | func mustLocalSession(t *testing.T, entry *session) *session { | |
| 150 | 149 | t.Helper() | |
| 151 | - | sess, ok := localSession(entry) | |
| 152 | - | require.True(t, ok) | |
| 153 | - | return sess | |
| 150 | + | require.NotNil(t, entry) | |
| 151 | + | return entry | |
| 154 | 152 | } | |
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
| @@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te | |||
| 38 | 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 39 | 39 | require.True(t, ok) | |
| 40 | 40 | pty.EXPECT().Close().Return(nil).Once() | |
| 41 | - | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 41 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 42 | 42 | ||
| 43 | 43 | require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 44 | 44 | require.Equal(t, []string{"delete incarnation"}, repository.calls) | |
M internal/usecase/daemon/session_rename_purge_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |||
| 16 | 16 | store, state := newMockStore(t) | |
| 17 | 17 | WithStore(t, store)(d) | |
| 18 | 18 | sess := newSnapshotTestSession(t, "old", false, "/work") | |
| 19 | - | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 19 | + | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 20 | 20 | sess.mu.Lock() | |
| 21 | 21 | record := sess.persistRecordLocked(1) | |
| 22 | 22 | sess.mu.Unlock() | |
M internal/usecase/daemon/status.go +8 -15
| @@ -240,24 +240,21 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||
| 240 | 240 | // palette interaction has captured recent-session hints. Normal palette state | |
| 241 | 241 | // continues to use the canonical, live MRU list. | |
| 242 | 242 | func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 243 | - | var entry attachmentSession | |
| 243 | + | var entry *session | |
| 244 | 244 | if cur != nil { | |
| 245 | 245 | entry = cur | |
| 246 | 246 | } | |
| 247 | 247 | return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent) | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | - | // barStateForAttachmentPaletteHints composes daemon-owned chrome for either | |
| 251 | - | // attachment implementation. Bar scripts remain local-session-only because | |
| 250 | + | // barStateForAttachmentPaletteHints composes daemon-owned chrome for an | |
| 251 | + | // attachment. Bar scripts remain local-session-only because | |
| 252 | 252 | // their existing execution contract depends on local tabs and PTYs. | |
| 253 | - | func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 253 | + | func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 254 | 254 | return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent) | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | - | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 258 | - | if attachmentSessionCore(cur) == nil { | |
| 259 | - | cur = nil | |
| 260 | - | } | |
| 257 | + | func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState { | |
| 261 | 258 | ranked := rankedRecentForHints(hints, recent) | |
| 262 | 259 | state := barState{statusFeedback: statusFeedback} | |
| 263 | 260 | if d != nil { | |
| @@ -268,14 +265,10 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac | |||
| 268 | 265 | if d != nil { | |
| 269 | 266 | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 270 | 267 | } | |
| 271 | - | if local, ok := localSession(cur); ok { | |
| 272 | - | state.status = local.statusSegmentsFor(ac, includeTerminalTitle) | |
| 273 | - | } else { | |
| 274 | - | state.status = cur.statusSegments(includeTerminalTitle) | |
| 275 | - | } | |
| 268 | + | state.status = cur.statusSegmentsFor(ac, includeTerminalTitle) | |
| 276 | 269 | } | |
| 277 | - | if local, ok := localSession(cur); ok && d != nil { | |
| 278 | - | state.topRight, state.bottomRight = d.barScriptSnapshot(local) | |
| 270 | + | if cur != nil && d != nil { | |
| 271 | + | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| 279 | 272 | } | |
| 280 | 273 | if ranked != nil { | |
| 281 | 274 | state.rankedRecent = ranked | |
M internal/usecase/daemon/status_test.go +3 -18
| @@ -22,20 +22,6 @@ import ( | |||
| 22 | 22 | "github.com/bnema/vev/pkg/renderer" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | - | // --- test doubles ----------------------------------------------------------- | |
| 26 | - | ||
| 27 | - | type coreNilAttachment struct{} | |
| 28 | - | ||
| 29 | - | func (*coreNilAttachment) core() *sessionCore { return nil } | |
| 30 | - | func (*coreNilAttachment) snapshotView(viewOptions) sessionView { | |
| 31 | - | return sessionView{} | |
| 32 | - | } | |
| 33 | - | func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} } | |
| 34 | - | func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) { | |
| 35 | - | return nil, false | |
| 36 | - | } | |
| 37 | - | func (*coreNilAttachment) validTargetTabLocked(int) bool { return false } | |
| 38 | - | ||
| 39 | 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 40 | 26 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 41 | 27 | ||
| @@ -931,18 +917,17 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 931 | 917 | } | |
| 932 | 918 | ||
| 933 | 919 | func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) { | |
| 934 | - | var nilEntry attachmentSession | |
| 920 | + | var nilEntry *session | |
| 935 | 921 | var typedNil *session | |
| 936 | 922 | valid := &session{sessionCore: sessionCore{id: "valid"}} | |
| 937 | 923 | ||
| 938 | 924 | for _, tc := range []struct { | |
| 939 | 925 | name string | |
| 940 | - | a attachmentSession | |
| 941 | - | b attachmentSession | |
| 926 | + | a *session | |
| 927 | + | b *session | |
| 942 | 928 | }{ | |
| 943 | 929 | {name: "nil interfaces", a: nilEntry, b: nilEntry}, | |
| 944 | 930 | {name: "typed nil", a: typedNil, b: valid}, | |
| 945 | - | {name: "core nil", a: &coreNilAttachment{}, b: valid}, | |
| 946 | 931 | } { | |
| 947 | 932 | t.Run(tc.name, func(t *testing.T) { | |
| 948 | 933 | require.NotPanics(t, func() { | |
M internal/usecase/daemon/transactional_resize.go +1 -1
| @@ -848,7 +848,7 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s | |||
| 848 | 848 | if ac == nil { | |
| 849 | 849 | return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate) | |
| 850 | 850 | } | |
| 851 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 851 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 852 | 852 | return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate) | |
| 853 | 853 | } | |
| 854 | 854 | ||
M internal/usecase/daemon/transactional_resize_test.go +4 -4
| @@ -410,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 410 | 410 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| 411 | 411 | observer := &daemonRuntimeObserver{} | |
| 412 | 412 | d.runtimeObserver = observer | |
| 413 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 413 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 414 | 414 | lease := rc.attachmentLease(ac) | |
| 415 | 415 | var newer uint64 | |
| 416 | 416 | d.beforeSessionResizePublication = func() { | |
| @@ -471,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 471 | 471 | t.Run(tc.name, func(t *testing.T) { | |
| 472 | 472 | pty := &transactionalResizePTY{} | |
| 473 | 473 | d, sess, ac, _ := newManualSessionWithPTYs(t, pty) | |
| 474 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 474 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 475 | 475 | lease := rc.attachmentLease(ac) | |
| 476 | 476 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |
| 477 | 477 | require.NotZero(t, epoch) | |
| @@ -555,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 555 | 555 | tb.mu.Unlock() | |
| 556 | 556 | owner := publishPaneOwner(popup, source, tb, 7) | |
| 557 | 557 | ||
| 558 | - | rc := d.attachCoordinator(source, ac, true) | |
| 558 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 559 | 559 | lease := rc.attachmentLease(ac) | |
| 560 | 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 561 | 561 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| @@ -780,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||
| 780 | 780 | tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7} | |
| 781 | 781 | tb.mu.Unlock() | |
| 782 | 782 | ||
| 783 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 783 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 784 | 784 | lease := rc.attachmentLease(ac) | |
| 785 | 785 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 786 | 786 | require.NotZero(t, epoch) | |
M internal/usecase/daemon/transport_observability_test.go +1 -1
| @@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||
| 20 | 20 | // One in-flight state makes the second public resize take the ACK-blocked | |
| 21 | 21 | // path. The real coordinator owns both renders and the ACK wake. | |
| 22 | 22 | ac.output = newOutputStateStream(1) | |
| 23 | - | rc := d.attachCoordinator(sess, ac, true) | |
| 23 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 24 | 24 | ||
| 25 | 25 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) { | |
| 26 | 26 | t.Fatal("first resize was not accepted") | |
M internal/usecase/daemon/wire.go +3 -1
| @@ -7,13 +7,15 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | |
| 10 | + | s.mu.Lock() | |
| 10 | 11 | w := ports.Welcome{ | |
| 11 | 12 | SessionID: string(s.id), | |
| 12 | 13 | SessionName: s.name, | |
| 13 | 14 | Ephemeral: s.ephemeral, | |
| 14 | 15 | Capabilities: ports.CapabilityResume, | |
| 15 | - | ResumeToken: ac.resumeToken, | |
| 16 | 16 | } | |
| 17 | + | s.mu.Unlock() | |
| 18 | + | w.ResumeToken = ac.resumeToken | |
| 17 | 19 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | |
| 18 | 20 | } | |
| 19 | 21 | ||
A internal/usecase/daemon/wire_metadata_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) { | |
| 12 | + | sess := &session{sessionCore: sessionCore{id: "session", name: "initial"}} | |
| 13 | + | ac := &attachedClient{resumeToken: 42} | |
| 14 | + | ||
| 15 | + | var wg sync.WaitGroup | |
| 16 | + | wg.Go(func() { | |
| 17 | + | for i := range 1000 { | |
| 18 | + | sess.mu.Lock() | |
| 19 | + | sess.name = fmt.Sprintf("session-%d", i) | |
| 20 | + | sess.ephemeral = i%2 == 0 | |
| 21 | + | sess.mu.Unlock() | |
| 22 | + | } | |
| 23 | + | }) | |
| 24 | + | ||
| 25 | + | for range 1000 { | |
| 26 | + | frame := frameWelcome(sess, ac) | |
| 27 | + | welcome, err := ports.UnmarshalWelcome(frame.Payload) | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatalf("UnmarshalWelcome() error = %v", err) | |
| 30 | + | } | |
| 31 | + | if welcome.SessionID != "session" || welcome.SessionName == "" { | |
| 32 | + | t.Fatalf("welcome metadata = %+v", welcome) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | wg.Wait() | |
| 36 | + | } | |
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 | + | } | |