detached 183b9e4

Merge pull request #176 from bnema/chore/finalize-unified-session-access

chore(release): finalize unified session access
commit
183b9e4b090dd606aefd349a61138db39579d679
author
bnema · · 2026-08-04 14:42 UTC
committer
GitHub · · 2026-08-04 14:42 UTC
merge of
785b650 + 64916bf · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
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 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.64 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
65 65
66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.66 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25` (`MsgAttachTarget`). Types `14–15` and `24` remain reserved.
68 - Version negotiation requires strict equality.68 - Version negotiation requires strict equality.
69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.69 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
70 - Bump `ProtocolVersion` for any message layout change.70 - Bump `ProtocolVersion` for any message layout change.
@@ -74,6 +74,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
74 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.74 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
75 - Named sessions survive headless and persist across daemon restarts.75 - Named sessions survive headless and persist across daemon restarts.
76 - The daemon starts on first use and exits when the last session ends.76 - The daemon starts on first use and exits when the last session ends.
77-- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.77+- Each connection has a 15-second handshake budget from connect through the initial committed publication.
78-- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.78+- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79-- Only one client may attach to a session; a new attach replaces the old client.79+- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80+- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.
81+- Command requests have a 10-second result deadline and are tracked per connection.
M README.md +4 -4
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
51 51
52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
53 53
54-Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.
55 55
56 ## Keys56 ## Keys
57 57
@@ -73,13 +73,13 @@ The palette does the rest: type a short code (`SPR` split right, `CNT` new tab,
73 vev attach user@host[:session]73 vev attach user@host[:session]
74 ```74 ```
75 75
76-SSH bootstraps the connection, then the session runs over UDP and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):76+The client opens a direct connection to the selected remote daemon. UDP is the default: SSH bootstraps an authenticated UDP endpoint, then the session runs over that connection and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
77 77
78 ```sh78 ```sh
79 sudo ufw allow 61000:61023/udp79 sudo ufw allow 61000:61023/udp
80 ```80 ```
81 81
82-Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).82+Where UDP is not an option, set `VEV_REMOTE_TRANSPORT=stdio` to keep the direct connection inside SSH; this is an explicit transport choice. Every connection has a 15-second handshake deadline, and each command request has a 10-second deadline. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
83 83
84 List sessions on a known host (OpenSSH resolves aliases from your SSH config):84 List sessions on a known host (OpenSSH resolves aliases from your SSH config):
85 85
@@ -119,7 +119,7 @@ vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destinat
119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>
120 ```120 ```
121 121
122-Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client.122+Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its attachment follows the moved pane or tab and activates the destination; existing destination attachments keep their own views.
123 123
124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
125 125
M docs/performance.md +69 -185
@@ -1,212 +1,96 @@
1 # Performance measurement1 # Performance measurement
2 2
3-## S5a transport observability3+vev measures the current session and attachment stack with process-local runtime
4-4+marks and harness-owned end-to-end boundaries. Measurement does not alter
5-S5a is measurement-only. It records process-local runtime marks and harness-owned5+rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`.
6-end-to-end boundaries without changing rendering, pacing, transport policy, wire6+
7-bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/7+## Canonical matrix
8-transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):8+
9-`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;9+The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
10-idle, active/all/inactive output, interactive flood, copy search, resize sweep,10+
11-snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP11+- topologies `1x4`, `4x1`, `4x4`, and `8x1`;
12-baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),12+- `120x40` terminal content with 10,000 history rows per pane;
13-with inapplicable cases required to be explicit. The seven transport entries are13+- idle, active output, all/inactive output, interactive flood, copy search,
14-`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,14+ resize sweep, snapshot/output/resize, and attach/restore/tab switch;
15-and `udp_loss_1pct`.15+- local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1%
16-16+ loss.
17-### Trace schema and clock domains17+
18+The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19+transports). Inapplicable scenarios stay present with an explicit reason.
20+Multiple-attachment scenarios measure shared session mutations together with
21+independent attachment views and output streams.
22+
23+A connection handshake is bounded by 15 seconds. A command request is bounded
24+by 10 seconds. These limits apply during measurement as well as normal use.
25+
26+## Trace schema and clocks
18 27
19 Every process JSONL record has exactly these fields:28 Every process JSONL record has exactly these fields:
20-`schema` (uint), `process_id` (string), `component` (string), `scenario`29+`schema`, `process_id`, `component`, `scenario`, `run`, `sequence`, `request_id`,
21-(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),30+`epoch`, `kind`, `tick`, `bytes`, `fragments`, `retransmits`, `pending`,
22-`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),31+`ack_rtt_nanos`, and `valid`.
23-`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:32+
33+Schema 1 is represented by:
24 34
25 ```json35 ```json
26 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}36 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
27 ```37 ```
28 38
29-`tick` is stamped only by the concrete JSONL observer using its process-local39+The concrete JSONL observer stamps `tick` with its process-local injected
30-injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,40+monotonic clock; producers supply no timestamp. Components in one OS process
31-`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every41+share that observer. The harness owns a separate monotonic clock for the
32-launched role, identifying its exclusive trace path and process/scenario/run42+input-injection to successfully-flushed-terminal-bytes boundary. Ticks from
33-mapping. Components in one OS43+different processes or clock domains are never ordered or subtracted. Records
34-process share that observer. A spawned daemon is deliberately stripped of44+correlate through scenario, run, sequence, request, and epoch IDs.
35-these variables so it cannot append to a role's trace. The harness clock is a
36-separate monotonic domain and is the sole clock for input-injection → terminal-
37-bytes-successfully-flushed end-to-end samples. Cross-process ticks are never
38-ordered or subtracted; records correlate only by scenario/run/sequence/request/
39-epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false`
40-record explicitly reports bounded-observer loss; spans crossing that gap are
41-excluded, while an unmatched end without such a gap remains an error.
42-
43-The named process-local spans are capture, compose, diff, queue wait,
44-ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
45-marks are paired within one process and summarized by component/adapter with
46-raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
47-retransmits, pending, ACK RTT) remain separate from duration calculations.
48-
49-### Historical proxy ANSI baseline
50-
51-Before this refactor, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. This parser benchmark remains
53-a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
54-guardrail.
55-
56-The binaries were built from clean, exact commits in temporary clones so their
57-provenance is embedded and checkable:
58 45
59-```sh46+Process-local spans cover capture, compose, diff, queue wait, acknowledgement
60-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone47+blocking, emit, adapter send, and adapter receive. Bytes, fragments,
61-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c48+retransmits, pending work, and acknowledgement RTT remain diagnostics rather
62-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone49+than duration inputs. Reports retain raw records, counts, p50/p95/p99, and max
63-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df050+where applicable.
64-(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
65-(cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .)
66-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone
67-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d
68-(cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness)
69-go version -m /tmp/vev-proxy-baseline-1f2c802
70-go version -m /tmp/vev-proxy-candidate-2b48abf9
71-go version -m /tmp/vev-perf-harness-d479fe9
72-```
73 51
74-The baseline benchmark below is a measured median of ten repetitions on52+## Running the matrix
75-Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
76-`1f2c802d0707785cab979134db6a95645250a82c`:
77 53
78-```sh54+Use a clean build and at least a 10-second warmup, a 30-second measured
79-cd /tmp/vev-perf-baseline-clone55+interval, and ten independent repetitions:
80-go test ./internal/usecase/daemon -run '^$' \
81- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
82- | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt
83-```
84-
85-| ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
86-| --- | ---: | ---: | ---: | ---: |
87-| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
88-| 120-column full line | 4,777 | 130 | 1 | 126 |
89-| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
90-| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
91-
92-Keep the ordinary local ANSI renderer as a separate guardrail:
93 56
94 ```sh57 ```sh
95-cd /tmp/vev-perf-candidate-2b48abf9-clone58+go build -o /tmp/vev ./
96-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \59+rm -rf testdata/perf/results
97- | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt60+go run ./cmd/vev-perf-harness \
61+ --vev-bin /tmp/vev \
62+ --manifest testdata/perf/manifest.json \
63+ --out testdata/perf/results \
64+ --warmup 10s --duration 30s --repetitions 10
98 ```65 ```
99 66
100-The candidate guardrail completed with Go 1.26.5; its ten-repetition output is67+Each run writes `manifest.json`, `raw-harness.jsonl`, one trace per launched
101-retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.68+process, `runs.json`, and `summary.json`. The process manifest records process
69+identity, role, scenario/run, clock domain, and its exclusive trace path.
102 70
103-### Public-CLI transport runs71+The harness injects at most one measured event per second. A sample requires
72+both input injection and successful terminal flush inside the measured
73+interval; warmup, straddling, and failed events remain raw diagnostics. Reject
74+results with missing or unmatched boundaries, invalid correlation,
75+nonmonotonic same-process sequence, negative same-domain duration, fewer than
76+10 complete in-interval pairs, invalid denominators, cross-process tick math,
77+less than 30 seconds measured, or fewer than 10 repetitions.
104 78
105-The adoption gate requires a 10-second warmup, a 30-second measured interval,79+Before a full run, validate the manifest and harness:
106-and ten independent repetitions for each of seven workloads × five transports
107-(35 scenarios), for both exact baseline and candidate binaries. This exact
108-command template was executed for every scenario (the two 35-scenario sets
109-were run separately to avoid oversubscribing the host):
110 80
111 ```sh81 ```sh
112-for bin in baseline candidate; do82+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
113- case "$bin" in83+go test ./cmd/vev-perf-harness
114- baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
115- candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
116- esac
117- for workload in active_output all_output inactive_output interactive_flood resize_sweep \
118- snapshot_output_resize attach_restore_tab_switch; do
119- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
120- scenario="1x4-${workload}-${transport}"
121- /tmp/vev-perf-harness-d479fe9 \
122- --vev-bin "$vev_bin" \
123- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
124- --scenario "$scenario" \
125- --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
126- --warmup 10s --duration 30s --repetitions 10
127- done
128- done
129-done
130 ```84 ```
131 85
132-The measured outputs are retained only under `/tmp/vev-perf-results/`. Each86+For a bounded local check, add `--scenario 1x4-idle-local`. The harness still
133-binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a87+validates the complete manifest before selecting that scenario. Do not commit
134-summary. The common completed results below are harness end-to-end medians88+result directories; raw traces and run manifests are the measurement evidence.
135-from `summary.json` (p50/p95, ns):
136-
137-| scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 |
138-| --- | ---: | ---: | ---: | ---: |
139-| 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 |
140-| 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 |
141-| 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 |
142-| 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 |
143-| 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 |
144-| 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 |
145-| 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 |
146-| 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 |
147-| 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 |
148-| 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 |
149-| 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 |
150-| 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 |
151-| 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 |
152-
153-`1x4-active_output-ssh_stdio` now completes for both binaries, so the former
154-trace-correlation blocker is fixed. The remaining failed scenarios are
155-retained as stderr evidence: all five `interactive_flood`, all five
156-`resize_sweep`, all five `snapshot_output_resize`, the four non-local
157-`attach_restore_tab_switch` transports, and the three `udp_loss_1pct`
158-output scenarios. They report terminal-start/readiness failures or missing
159-receive span pairs; they are not converted into samples.
160-
161-The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid
162-A/B summaries, so the 5% gate and adoption cannot be declared. The completed
163-common p50 values happened to stay within ±1.12%, but that is not a gate
164-result and does not substitute for the 22 missing scenarios.
165-
166-`summary.json` provenance was checked for every completed result:
167-`git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with
168-`vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and
169-`vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The
170-complete 252-scenario canonical matrix was not run because the representative
171-gate was incomplete. Its exact command remains:
172 89
173-```sh90+## In-process checks
174-rm -rf /tmp/vev-perf-results/public-full-baseline
175-/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
176- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
177- --out /tmp/vev-perf-results/public-full-baseline \
178- --warmup 10s --duration 30s --repetitions 10
179-```
180 91
181-Warmup is excluded. During the measured interval the harness injects at most92+These commands measure local daemon or VT work only, not end-to-end transport
182-one event per second. At least 10 complete event pairs are required: both input93+latency:
183-injection and successful terminal flush must fall inside the interval.
184-Straddling or failed events remain raw diagnostics, not samples. Each run must
185-contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and
186-`summary.json`.
187-
188-`summary.json` records the harness source as `git_sha`, the measured vev binary
189-source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
190-manifests, and JSONL identify scenario, run, process, component, and correlation
191-IDs. Preserve those files with the benchmark output and record
192-`go version -m /tmp/vev-proxy-baseline-1f2c802`,
193-`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
194-`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
195-CPU and allocation evidence comes from Go microbenchmarks and process-local
196-span durations. End-to-end latency, terminal flush, and byte evidence comes
197-from the public-CLI harness. Reset, snapshot, and span counters for structured
198-output are candidate-only until that output exists.
199-
200-Reject results with missing input/flush boundaries, unmatched IDs, bad
201-correlation, nonmonotonic same-process sequence, negative same-domain spans,
202-fewer than 10 complete in-interval event pairs, invalid denominators, any
203-cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL
204-and run manifests are evidence; no result alone authorizes an optimization.
205-
206-## Existing in-process smoke checks
207-
208-These commands measure local daemon work only and are not end-to-end transport
209-results:
210 94
211 ```sh95 ```sh
212 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem96 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
M docs/remote-resilience.md +53 -27
@@ -1,45 +1,71 @@
1 # Remote resilience1 # Remote resilience
2 2
3-Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.3+Remote attachments connect directly to the selected vev daemon. UDP is the
4+normal carriage: SSH bootstraps an authenticated endpoint, then the attachment
5+carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
6+SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7+the remote host.
4 8
5-The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.9+The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10+panes, a fixed PTY content size, and ordered mutations. Each attachment keeps
11+its own window size, selected tab and pane, copy mode, overlays, render/output
12+acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13+attachment without replacing or changing its peers.
6 14
7-## Operational model15+## Connection limits
8 16
9-- SSH bootstrap starts or finds the remote UDP endpoint.17+- A connection handshake has one 15-second deadline from connection start
10-- UDP carries normal session traffic after bootstrap. The remote proxy binds a port in a fixed range, `61000-61023` by default; `VEV_UDP_PORT_RANGE` overrides it (a range, a single port, or `0` for a random ephemeral port).18+ through Hello, Welcome, and the initial committed publication. The deadline
11-- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.19+ applies to local and remote connections.
12-- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.20+- Each command request has a 10-second deadline for its result. A timeout
21+ abandons only that request; it does not complete a later request with a stale
22+ result.
23+- The first valid attachment establishes the session's PTY content size. A
24+ later attachment can resize its own window without resizing shared PTYs.
13 25
14-## Recovery states26+## UDP operation
15 27
16-Resilience work uses these user-facing states:28+- The remote host selects a UDP port in `61000-61023` by default. Set
29+ `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
30+- The UDP carriage authenticates and retransmits protocol frames, preserves
31+ frame order, and reports bounded link-health state without logging payloads,
32+ addresses, keys, or identities.
33+- The attachment reconnects with its resume token after a path change. The
34+ remote session and its PTYs remain in place while the attachment is offline.
17 35
18-| State | Meaning |36+The raw UDP AEAD key is held only in memory during bootstrap and transport
19-| --- | --- |37+setup. It is not written to durable state. Key buffers are cleared on the
20-| `connected` | a complete record or meaningful ACK progress is recent |38+reachable handoff paths on a best-effort basis; cipher implementations and
21-| `degraded` | authenticated UDP packets still arrive, but complete-record or ACK progress is delayed |39+other I/O owners may retain internal copies for their lifetime.
22-| `probing` | authenticated UDP packet contact is absent, so vev probes for path recovery |
23-| `resuming` | a replacement transport is attaching to the same session |
24-| `offline` | authenticated UDP contact remains absent; vev re-runs the SSH bootstrap and re-dials UDP with the resume token |
25-| `expired` | the session or recovery window is no longer available |
26 40
27-When a terminal is in raw mode, recovery messages appear as vev status text rather than as bytes written into the remote program's output stream.41+## Connection states
28 42
29-`VEV_REMOTE_TRANSPORT=stdio` is the SSH-stdio fallback: it keeps the session inside authenticated SSH when UDP is blocked or unavailable. It remains available while UDP recovery is attempted.43+| State | Meaning |
44+| --- | --- |
45+| `connected` | Recent authenticated contact and complete frame progress. |
46+| `degraded` | Authenticated packets arrive, but complete-frame or acknowledgement progress is delayed. |
47+| `probing` | Authenticated contact is absent and the carriage is probing for a recovered path. |
48+| `resuming` | This attachment is opening a replacement connection with its resume token. |
49+| `offline` | The attachment is re-establishing the selected remote transport. |
50+| `expired` | The session or attachment resume window is no longer available. |
30 51
31-## UDP transport health diagnostics52+In raw terminal mode, state changes appear as vev status text rather than bytes
53+written into the PTY's output stream.
32 54
33-At debug logging, UDP transport-health lines report only state, elapsed progress ages, and bounded transport counters; they do not include payloads, addresses, keys, or identities. For a real remote attempt, `scripts/debug-remote-attach.sh --udp-health user@host` stores redacted transport-health lines and Linux `ss -u -m` socket-memory counters, then prints the exact collected file paths.55+## Diagnostics and checks
34 56
35-## Manual checks57+At debug level, transport-health logs contain only state, elapsed progress ages,
58+and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
59+collects redacted health lines and Linux `ss -u -m` socket-memory counters.
36 60
37-Useful scenarios for validating resilience behavior:61+Useful checks are:
38 62
39 - block and unblock the configured UDP port range;63 - block and unblock the configured UDP port range;
40 - move between networks or change VPN state;64 - move between networks or change VPN state;
41-- suspend and resume the client machine;65+- suspend and resume the attachment machine;
42-- retry `vev attach user@host:session` for the same named session;66+- reconnect to the same named session;
43-- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.67+- verify that a second attachment retains its independent view while the first
68+ reconnects.
44 69
45-Keep hostnames, usernames, and local paths out of shared logs and screenshots.70+Keep hostnames, usernames, keys, and local paths out of shared logs and
71+screenshots.
M docs/terminal.md +7 -1
@@ -8,12 +8,18 @@ When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORT
8 8
9 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.9 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
10 10
11-For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.11+The attaching client's exported environment is sent in the connection handshake and becomes the session's source of truth for future PTY children, including remote attaches. A later attachment updates that shared environment for children created afterward; already-running processes keep their original environment.
12 12
13 ## VT compatibility13 ## VT compatibility
14 14
15 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.15 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
16 16
17+## Session content size
18+
19+The first valid attachment establishes the session's fixed PTY content size. Each
20+attachment may have a different outer window and viewport; resizing it changes
21+only its attachment-local presentation and does not resize shared PTYs.
22+
17 ## Recovery23 ## Recovery
18 24
19 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.25 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.
M internal/adapters/sshstdio/transport.go +237 -30
@@ -12,6 +12,7 @@ import (
12 "fmt"12 "fmt"
13 "io"13 "io"
14 "log/slog"14 "log/slog"
15+ "os"
15 "os/exec"16 "os/exec"
16 "strings"17 "strings"
17 "sync"18 "sync"
@@ -33,6 +34,18 @@ var (
33 34
34 type closeFunc func() error35 type closeFunc func() error
35 type eofErrFunc func() error36 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 type Option func(*transport)50 type Option func(*transport)
38 51
@@ -57,7 +70,26 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc
57 if closeFn == nil {70 if closeFn == nil {
58 closeFn = func() error { return nil }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 type transport struct {95 type transport struct {
@@ -65,11 +97,14 @@ type transport struct {
65 w io.Writer97 w io.Writer
66 close closeFunc98 close closeFunc
67 eofErr eofErrFunc99 eofErr eofErrFunc
100+ readerCloser io.Closer
101+ writerCloser io.Closer
102+ done chan struct{}
68 observer ports.SerializedRuntimeObserver103 observer ports.SerializedRuntimeObserver
69 operationMu sync.Mutex104 operationMu sync.Mutex
70- operationCount int105+ operationCount [operationKindCount]int
71 closing bool106 closing bool
72- operationsDone chan struct{}107+ operationsDone [operationKindCount]chan struct{}
73 closeOnce sync.Once108 closeOnce sync.Once
74 closeErr error109 closeErr error
75 110
@@ -77,6 +112,154 @@ type transport struct {
77 readBuf []byte112 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 func (t *transport) Send(f ports.Frame) error {263 func (t *transport) Send(f ports.Frame) error {
81 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))264 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
82 n := 1 + len(f.Payload)265 n := 1 + len(f.Payload)
@@ -136,7 +319,11 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
136 if t.observer == nil {319 if t.observer == nil {
137 return func(bool) {}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 return func(bool) {}327 return func(bool) {}
141 }328 }
142 correlation := ports.NewRuntimeCorrelation()329 correlation := ports.NewRuntimeCorrelation()
@@ -146,7 +333,7 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
146 end = ports.RuntimeAdapterReceiveEnd333 end = ports.RuntimeAdapterReceiveEnd
147 }334 }
148 return func(valid bool) {335 return func(valid bool) {
149- defer t.finishObservedOperation()336+ defer t.finishObservedOperation(kind)
150 t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))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 func (t *transport) Close() error {351 func (t *transport) Close() error {
165 t.closeOnce.Do(func() {352 t.closeOnce.Do(func() {
166- done := t.beginShutdown()353+ wait := t.beginShutdown()
167 // Close owned streams before waiting for in-flight operations. The354 // Close owned streams before waiting for in-flight operations. The
168 // writer is what releases a Send blocked on a child stdin pipe; the355 // 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 var writeErr, readErr error359 var writeErr, readErr error
171- if w, ok := t.w.(io.Closer); ok {360+ if t.writerCloser != nil {
172- writeErr = w.Close()361+ writeErr = t.writerCloser.Close()
173 }362 }
174- if r, ok := t.r.(io.Closer); ok {363+ if t.readerCloser != nil {
175- readErr = r.Close()364+ readErr = t.readerCloser.Close()
176 }365 }
177 closeErr := t.close()366 closeErr := t.close()
178- if done != nil {367+ if t.writerCloser != nil && wait.send != nil {
179- <-done368+ <-wait.send
369+ }
370+ if t.readerCloser != nil && wait.receive != nil {
371+ <-wait.receive
180 }372 }
181 switch {373 switch {
182 case closeErr != nil:374 case closeErr != nil:
@@ -190,36 +382,41 @@ func (t *transport) Close() error {
190 return t.closeErr382 return t.closeErr
191 }383 }
192 384
193-func (t *transport) beginObservedOperation() bool {385+func (t *transport) beginObservedOperation(kind operationKind) bool {
194 t.operationMu.Lock()386 t.operationMu.Lock()
195 defer t.operationMu.Unlock()387 defer t.operationMu.Unlock()
196 if t.closing {388 if t.closing {
197 return false389 return false
198 }390 }
199- if t.operationCount == 0 {391+ if t.operationCount[kind] == 0 {
200- t.operationsDone = make(chan struct{})392+ t.operationsDone[kind] = make(chan struct{})
201 }393 }
202- t.operationCount++394+ t.operationCount[kind]++
203 return true395 return true
204 }396 }
205 397
206-func (t *transport) finishObservedOperation() {398+func (t *transport) finishObservedOperation(kind operationKind) {
207 t.operationMu.Lock()399 t.operationMu.Lock()
208 defer t.operationMu.Unlock()400 defer t.operationMu.Unlock()
209- t.operationCount--401+ t.operationCount[kind]--
210- if t.operationCount == 0 {402+ if t.operationCount[kind] == 0 {
211- close(t.operationsDone)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 t.operationMu.Lock()408 t.operationMu.Lock()
217 defer t.operationMu.Unlock()409 defer t.operationMu.Unlock()
218 t.closing = true410 t.closing = true
219- if t.operationCount == 0 {411+ close(t.done)
220- return nil412+ 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.operationsDone419+ return wait
223 }420 }
224 421
225 type processWaiter struct {422 type processWaiter struct {
@@ -344,10 +541,11 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
344 return dialContext(ctx, target, session, logger, opts...)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 a544+// DialContext is like Dial, but the context gates ssh startup. Once the
348-// canceled attach attempt interrupts the local ssh process. Callers may pass a545+// transport is returned, its Close method owns the subprocess lifetime; the
349-// logger to record ssh start failures and non-clean exits without logging the546+// handshake context must not kill an already-established carriage. Callers may
350-// generated command line.547+// pass a logger to record ssh start failures and non-clean exits without logging
548+// the generated command line.
351 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {549 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
352 var log *slog.Logger550 var log *slog.Logger
353 if len(logger) > 0 {551 if len(logger) > 0 {
@@ -361,7 +559,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
361 return nil, err559 return nil, err
362 }560 }
363 spec := BuildCommand(target, session)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 stdin, err := cmd.StdinPipe()568 stdin, err := cmd.StdinPipe()
366 if err != nil {569 if err != nil {
367 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)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 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)585 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
383 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)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 for _, opt := range opts {591 for _, opt := range opts {
385 if opt != nil {592 if opt != nil {
386 opt(transport)593 opt(transport)
M internal/adapters/sshstdio/transport_observability_test.go +78 -1
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7+ "io"
7 "os"8 "os"
8 "strings"9 "strings"
9 "sync"10 "sync"
@@ -93,7 +94,7 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) {
93 Kind string `json:"kind"`94 Kind string `json:"kind"`
94 Valid bool `json:"valid"`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 var mark struct {98 var mark struct {
98 ProcessID string `json:"process_id"`99 ProcessID string `json:"process_id"`
99 Scenario string `json:"scenario"`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 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {206 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
130 tracePath := t.TempDir() + "/ssh.jsonl"207 tracePath := t.TempDir() + "/ssh.jsonl"
131 observer, closer, err := observability.NewJSONL(tracePath, sshRuntimeClock{now: time.Unix(0, 307)}, "ssh-process")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 // sshstdio.Close owns and closes writers that implement io.Closer so a1041 // sshstdio.Close owns and closes writers that implement io.Closer so a
1042 // blocked Send can unwind. Keep process-global stdin/stdout outside that1042 // blocked Send can unwind. Keep process-global stdin/stdout outside that
1043- // ownership boundary: input is a non-closing view and output is an owned1043+ // ownership boundary: sshstdio recognizes process stdin as a shared
1044- // pipe drained by a small forwarding goroutine.1044+ // cancellable pump, while output is an owned pipe drained by a forwarding
1045+ // goroutine.
1045 stdin, stdout := os.Stdin, os.Stdout1046 stdin, stdout := os.Stdin, os.Stdout
1046 stdoutReader, stdoutWriter := io.Pipe()1047 stdoutReader, stdoutWriter := io.Pipe()
1047 forwardDone := make(chan struct{})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 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")26 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
27 27
28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
29+var errInvalidEnum = errors.New("ports: invalid enum value")
29 30
30 const (31 const (
31 outputHeaderLen = 5*8 + 2*2 + 132 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 // Detach asks the daemon to detach the current client without killing the183 // Detach asks the daemon to detach the current client without killing the
174 // session.184 // session.
175 type Detach struct{}185 type Detach struct{}
@@ -214,7 +224,7 @@ type Output struct {
214 }224 }
215 225
216 // AttachTarget identifies the endpoint and session selected for a new226 // 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 type AttachTarget struct {228 type AttachTarget struct {
219 Endpoint string229 Endpoint string
220 Session string230 Session string
@@ -817,6 +827,9 @@ func UnmarshalTheme(b []byte) (Theme, error) {
817 if err != nil {827 if err != nil {
818 return Theme{}, err828 return Theme{}, err
819 }829 }
830+ if flags&^uint8(0x1f) != 0 {
831+ return Theme{}, errInvalidEnum
832+ }
820 fgR, err := r.getUint8()833 fgR, err := r.getUint8()
821 if err != nil {834 if err != nil {
822 return Theme{}, err835 return Theme{}, err
@@ -994,11 +1007,11 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
994 if m.SessionName, err = r.getString(); err != nil {1007 if m.SessionName, err = r.getString(); err != nil {
995 return Welcome{}, err1008 return Welcome{}, err
996 }1009 }
997- eph, err := r.getUint8()1010+ var ephemeral bool
998- if err != nil {1011+ if ephemeral, err = r.getBool(); err != nil {
999 return Welcome{}, err1012 return Welcome{}, err
1000 }1013 }
1001- m.Ephemeral = eph != 01014+ m.Ephemeral = ephemeral
1002 if m.ResumeToken, err = r.getUint64(); err != nil {1015 if m.ResumeToken, err = r.getUint64(); err != nil {
1003 return Welcome{}, err1016 return Welcome{}, err
1004 }1017 }
@@ -1185,6 +1198,9 @@ func UnmarshalDetached(b []byte) (Detached, error) {
1185 if err != nil {1198 if err != nil {
1186 return Detached{}, err1199 return Detached{}, err
1187 }1200 }
1201+ if !validDetachedReason(reason) {
1202+ return Detached{}, errInvalidEnum
1203+ }
1188 if err := r.done(); err != nil {1204 if err := r.done(); err != nil {
1189 return Detached{}, err1205 return Detached{}, err
1190 }1206 }
@@ -1312,9 +1328,9 @@ func UnmarshalKill(b []byte) (Kill, error) {
1312 if err != nil {1328 if err != nil {
1313 return Kill{}, err1329 return Kill{}, err
1314 }1330 }
1315- var all uint81331+ var all bool
1316 if len(r.b) > 0 {1332 if len(r.b) > 0 {
1317- all, err = r.getUint8()1333+ all, err = r.getBool()
1318 if err != nil {1334 if err != nil {
1319 return Kill{}, err1335 return Kill{}, err
1320 }1336 }
@@ -1322,7 +1338,7 @@ func UnmarshalKill(b []byte) (Kill, error) {
1322 if err := r.done(); err != nil {1338 if err := r.done(); err != nil {
1323 return Kill{}, err1339 return Kill{}, err
1324 }1340 }
1325- return Kill{Name: name, All: all != 0}, nil1341+ return Kill{Name: name, All: all}, nil
1326 }1342 }
1327 1343
1328 // MarshalSessions encodes m into a Sessions message payload: a uint16 count1344 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -1365,19 +1381,19 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1365 if s.Name, err = r.getString(); err != nil {1381 if s.Name, err = r.getString(); err != nil {
1366 return Sessions{}, err1382 return Sessions{}, err
1367 }1383 }
1368- eph, err := r.getUint8()1384+ eph, err := r.getBool()
1369 if err != nil {1385 if err != nil {
1370 return Sessions{}, err1386 return Sessions{}, err
1371 }1387 }
1372- s.Ephemeral = eph != 01388+ s.Ephemeral = eph
1373 if s.Tabs, err = r.getUint16(); err != nil {1389 if s.Tabs, err = r.getUint16(); err != nil {
1374 return Sessions{}, err1390 return Sessions{}, err
1375 }1391 }
1376- att, err := r.getUint8()1392+ att, err := r.getBool()
1377 if err != nil {1393 if err != nil {
1378 return Sessions{}, err1394 return Sessions{}, err
1379 }1395 }
1380- s.Attached = att != 01396+ s.Attached = att
1381 state, err := r.getUint8()1397 state, err := r.getUint8()
1382 if err != nil {1398 if err != nil {
1383 return Sessions{}, err1399 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 import "github.com/bnema/vev/internal/ports"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 // attachmentConnectionToken is the exact capability for one registered5 // attachmentConnectionToken is the exact capability for one registered
15 // attachment connection incarnation. It is invalid as soon as any of the6 // attachment connection incarnation. It is invalid as soon as any of the
16 // session, attachment membership, connection generation, transport link, or7 // session, attachment membership, connection generation, transport link, or
17 // coordinator lease changes.8 // coordinator lease changes.
18 type attachmentConnectionToken struct {9 type attachmentConnectionToken struct {
19- sess attachmentSession10+ sess *session
20 ac *attachedClient11 ac *attachedClient
21 generation uint6412 generation uint64
22 transport transportSnapshot13 transport transportSnapshot
@@ -27,7 +18,7 @@ type attachmentConnectionToken struct {
27 rebase bool18 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 if entry == nil || entry.core() == nil || ac == nil || tr == nil {22 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
32 return attachmentConnectionToken{}23 return attachmentConnectionToken{}
33 }24 }
@@ -70,7 +61,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
70 }61 }
71 62
72 // current validates every immutable identity captured by the token. Membership63 // 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 func (t attachmentConnectionToken) current() bool {65 func (t attachmentConnectionToken) current() bool {
75 return t.sess != nil && t.ac != nil &&66 return t.sess != nil && t.ac != nil &&
76 t.ac.connectionGeneration.Load() == t.generation &&67 t.ac.connectionGeneration.Load() == t.generation &&
@@ -100,8 +91,8 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
100 return t.attachmentCurrent()91 return t.attachmentCurrent()
101 }92 }
102 93
103-func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {94+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil || lease.attachment != ac {95+ if sess == nil || ac == nil || lease == nil {
105 return nil, false96 return nil, false
106 }97 }
107 token := attachmentToken(sess, ac, ac.transport())98 token := attachmentToken(sess, ac, ac.transport())
M internal/usecase/daemon/attachment_effect_gate.go +1 -1
@@ -18,7 +18,7 @@ const (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {20 type attachmentCapability struct {
21- sess attachmentSession21+ sess *session
22 generation uint6422 generation uint64
23 transport transportSnapshot23 transport transportSnapshot
24 lease *attachmentLease24 lease *attachmentLease
M internal/usecase/daemon/attachment_effect_gate_test.go +18 -18
@@ -165,7 +165,7 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
165 165
166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168- rc := d.attachCoordinator(sess, ac, true)168+ rc := d.attachCoordinator(sess, nil, ac, true)
169 foreign := &attachmentLease{attachment: &attachedClient{}}169 foreign := &attachmentLease{attachment: &attachedClient{}}
170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)
171 require.False(t, admitted)171 require.False(t, admitted)
@@ -196,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
196 sess.registerAttachmentLocked(later)196 sess.registerAttachmentLocked(later)
197 sess.mu.Unlock()197 sess.mu.Unlock()
198 198
199- rc := d.attachCoordinator(sess, first, true)199+ rc := d.attachCoordinator(sess, nil, first, true)
200 firstToken := sess.attachmentToken(first, firstTransport)200 firstToken := sess.attachmentToken(first, firstTransport)
201 firstToken.lease = rc.attachmentLease(first)201 firstToken.lease = rc.attachmentLease(first)
202 first.publishAttachmentCapability(firstToken)202 first.publishAttachmentCapability(firstToken)
@@ -302,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
303 snatchedTransport := newTeardownBlockingTransport()303 snatchedTransport := newTeardownBlockingTransport()
304 snatched.replaceTransport(snatchedTransport)304 snatched.replaceTransport(snatchedTransport)
305- d.attachCoordinator(sess, snatched, true)305+ d.attachCoordinator(sess, nil, snatched, true)
306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
307 307
308 activeTransport := newTeardownBlockingTransport()308 activeTransport := newTeardownBlockingTransport()
@@ -322,7 +322,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
322 d.mu.Lock()322 d.mu.Lock()
323 d.sessions[unrelated.id] = unrelated323 d.sessions[unrelated.id] = unrelated
324 d.mu.Unlock()324 d.mu.Unlock()
325- d.attachCoordinator(unrelated, unrelatedClient, true)325+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326 326
327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328 activeToken := sess.attachmentToken(active, activeTransport)328 activeToken := sess.attachmentToken(active, activeTransport)
@@ -358,7 +358,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
359 stale := newTeardownBlockingTransport()359 stale := newTeardownBlockingTransport()
360 ac.replaceTransport(stale)360 ac.replaceTransport(stale)
361- d.attachCoordinator(sess, ac, true)361+ d.attachCoordinator(sess, nil, ac, true)
362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
363 363
364 token := sess.attachmentToken(ac, stale)364 token := sess.attachmentToken(ac, stale)
@@ -387,7 +387,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
388 transport := &closeTrackingTransport{}388 transport := &closeTrackingTransport{}
389 active.replaceTransport(transport)389 active.replaceTransport(transport)
390- d.attachCoordinator(sess, active, true)390+ d.attachCoordinator(sess, nil, active, true)
391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
392 392
393 owner := freezeAttachmentEffectGates(active)393 owner := freezeAttachmentEffectGates(active)
@@ -418,7 +418,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
419 blocked := newTeardownBlockingTransport()419 blocked := newTeardownBlockingTransport()
420 active.replaceTransport(blocked)420 active.replaceTransport(blocked)
421- d.attachCoordinator(sess, active, true)421+ d.attachCoordinator(sess, nil, active, true)
422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
423 423
424 token := sess.attachmentToken(active, blocked)424 token := sess.attachmentToken(active, blocked)
@@ -444,7 +444,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
445 transport := &closeTrackingTransport{}445 transport := &closeTrackingTransport{}
446 ac.replaceTransport(transport)446 ac.replaceTransport(transport)
447- rc := d.attachCoordinator(sess, ac, true)447+ rc := d.attachCoordinator(sess, nil, ac, true)
448 token := sess.attachmentToken(ac, transport)448 token := sess.attachmentToken(ac, transport)
449 token.lease = rc.attachmentLease(ac)449 token.lease = rc.attachmentLease(ac)
450 ac.publishAttachmentCapability(token)450 ac.publishAttachmentCapability(token)
@@ -474,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475 oldTransport := newBlockedRenderReplacementTransport(d, sess)475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
476 old.replaceTransport(oldTransport)476 old.replaceTransport(oldTransport)
477- rc := d.attachCoordinator(sess, old, true)477+ rc := d.attachCoordinator(sess, nil, old, true)
478 lease := rc.attachmentLease(old)478 lease := rc.attachmentLease(old)
479 token := sess.attachmentToken(old, oldTransport)479 token := sess.attachmentToken(old, oldTransport)
480 token.lease = lease480 token.lease = lease
@@ -529,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
530 oldTransport := newDatagramTestTransport()530 oldTransport := newDatagramTestTransport()
531 old.replaceTransport(oldTransport)531 old.replaceTransport(oldTransport)
532- rc := d.attachCoordinator(sess, old, true)532+ rc := d.attachCoordinator(sess, nil, old, true)
533 token := sess.attachmentToken(old, oldTransport)533 token := sess.attachmentToken(old, oldTransport)
534 token.lease = rc.attachmentLease(old)534 token.lease = rc.attachmentLease(old)
535 old.publishAttachmentCapability(token)535 old.publishAttachmentCapability(token)
@@ -630,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
630 oldTransport := newDatagramTestTransport()630 oldTransport := newDatagramTestTransport()
631 old.replaceTransport(oldTransport)631 old.replaceTransport(oldTransport)
632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)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 token := sess.attachmentToken(old, oldTransport)634 token := sess.attachmentToken(old, oldTransport)
635 token.lease = rc.attachmentLease(old)635 token.lease = rc.attachmentLease(old)
636 if tt.setup != nil {636 if tt.setup != nil {
@@ -683,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
683 target.tabs[1].attentionAt = time.Unix(1, 0)683 target.tabs[1].attentionAt = time.Unix(1, 0)
684 d.sessions[target.id] = target684 d.sessions[target.id] = target
685 685
686- rc := d.attachCoordinator(source, ac, true)686+ rc := d.attachCoordinator(source, nil, ac, true)
687 token := source.attachmentToken(ac, ac.transport())687 token := source.attachmentToken(ac, ac.transport())
688 token.lease = rc.attachmentLease(ac)688 token.lease = rc.attachmentLease(ac)
689 ac.publishAttachmentCapability(token)689 ac.publishAttachmentCapability(token)
@@ -701,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
701 d.enterPicker(sess, old)701 d.enterPicker(sess, old)
702 702
703 oldTransport := old.transport()703 oldTransport := old.transport()
704- rc := d.attachCoordinator(sess, old, true)704+ rc := d.attachCoordinator(sess, nil, old, true)
705 token := sess.attachmentToken(old, oldTransport)705 token := sess.attachmentToken(old, oldTransport)
706 token.lease = rc.attachmentLease(old)706 token.lease = rc.attachmentLease(old)
707 old.publishAttachmentCapability(token)707 old.publishAttachmentCapability(token)
@@ -745,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
745 defer release()745 defer release()
746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
747 d.enterPicker(sess, ac)747 d.enterPicker(sess, ac)
748- rc := d.attachCoordinator(sess, ac, true)748+ rc := d.attachCoordinator(sess, nil, ac, true)
749 token := sess.attachmentToken(ac, ac.transport())749 token := sess.attachmentToken(ac, ac.transport())
750 token.lease = rc.attachmentLease(ac)750 token.lease = rc.attachmentLease(ac)
751 ac.publishAttachmentCapability(token)751 ac.publishAttachmentCapability(token)
@@ -762,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
763 oldTransport := newDatagramTestTransport()763 oldTransport := newDatagramTestTransport()
764 old.replaceTransport(oldTransport)764 old.replaceTransport(oldTransport)
765- rc := d.attachCoordinator(sess, old, true)765+ rc := d.attachCoordinator(sess, nil, old, true)
766 token := sess.attachmentToken(old, oldTransport)766 token := sess.attachmentToken(old, oldTransport)
767 token.lease = rc.attachmentLease(old)767 token.lease = rc.attachmentLease(old)
768 768
@@ -796,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
797 aTransport := newDatagramTestTransport()797 aTransport := newDatagramTestTransport()
798 a.replaceTransport(aTransport)798 a.replaceTransport(aTransport)
799- d.attachCoordinator(first, a, true)799+ d.attachCoordinator(first, nil, a, true)
800 first.attachmentToken(a, aTransport)800 first.attachmentToken(a, aTransport)
801 801
802 bTransport := newDatagramTestTransport()802 bTransport := newDatagramTestTransport()
@@ -807,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
807 d.mu.Lock()807 d.mu.Lock()
808 d.sessions[second.id] = second808 d.sessions[second.id] = second
809 d.mu.Unlock()809 d.mu.Unlock()
810- d.attachCoordinator(second, b, true)810+ d.attachCoordinator(second, nil, b, true)
811 second.attachmentToken(b, bTransport)811 second.attachmentToken(b, bTransport)
812 812
813 start := make(chan struct{})813 start := make(chan struct{})
@@ -854,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
855 transport := newDatagramTestTransport()855 transport := newDatagramTestTransport()
856 ac.replaceTransport(transport)856 ac.replaceTransport(transport)
857- rc := d.attachCoordinator(sess, ac, true)857+ rc := d.attachCoordinator(sess, nil, ac, true)
858 token := sess.attachmentToken(ac, transport)858 token := sess.attachmentToken(ac, transport)
859 token.lease = rc.attachmentLease(ac)859 token.lease = rc.attachmentLease(ac)
860 ac.publishAttachmentCapability(token)860 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/attachment_registry.go +91 -69
@@ -23,13 +23,11 @@ type attachmentView struct {
23 revision uint6423 revision uint64
24 }24 }
25 25
26-// viewInvalidation records the order in which attachment views observed one26+// attachmentViewSnapshot is the immutable value returned to callers that need
27-// shared session mutation. The order is stable client-ID order, not map27+// to inspect attachment-local state without retaining its mutex.
28-// iteration or lock-acquisition order, which keeps concurrent attaches28+type attachmentViewSnapshot struct {
29-// deterministic.
30-type viewInvalidation struct {
31 attachment *attachedClient29 attachment *attachedClient
32- revision uint6430+ view attachmentView
33 }31 }
34 32
35 func (ac *attachedClient) viewSnapshot() attachmentView {33 func (ac *attachedClient) viewSnapshot() attachmentView {
@@ -41,11 +39,7 @@ func (ac *attachedClient) viewSnapshot() attachmentView {
41 return ac.view39 return ac.view
42 }40 }
43 41
44-func (ac *attachedClient) publishView(view attachmentView) {42+func (ac *attachedClient) publishViewLocked(view attachmentView) {
45- if ac == nil {
46- return
47- }
48- ac.viewMu.Lock()
49 // Rebase while viewMu is held, before the new revision becomes visible.43 // Rebase while viewMu is held, before the new revision becomes visible.
50 // Output fences take the same lock, so no side effect can pair the new44 // Output fences take the same lock, so no side effect can pair the new
51 // ViewRevision with the previous output epoch. Test/headless fixtures may45 // 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 ac.view = view56 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 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must81 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
@@ -96,7 +111,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
96 return true111 return true
97 }112 }
98 113
99-func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {114+func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
100 if entry == nil || entry.core() == nil {115 if entry == nil || entry.core() == nil {
101 return false116 return false
102 }117 }
@@ -138,7 +153,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
138 return true153 return true
139 }154 }
140 155
141-func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {156+func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
142 if entry == nil || entry.core() == nil {157 if entry == nil || entry.core() == nil {
143 return false158 return false
144 }159 }
@@ -156,26 +171,26 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
156 return s.unregisterAttachmentLocked(ac)171 return s.unregisterAttachmentLocked(ac)
157 }172 }
158 173
159-// snapshotAttachmentsLocked returns a stable registration-ordered snapshot.174+func (s *session) snapshotAttachmentsLocked() []*attachedClient {
160-// Callers hold s.mu; returned attachments may be used after unlocking.175+ if s == nil || len(s.attachments) == 0 {
161-func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
162- if c == nil || len(c.attachments) == 0 {
163 return nil176 return nil
164 }177 }
165- out := make([]*attachedClient, 0, len(c.attachments))178+ out := make([]*attachedClient, 0, len(s.attachments))
166- for ac := range c.attachments {179+ for ac := range s.attachments {
167 out = append(out, ac)180 out = append(out, ac)
168 }181 }
169 slices.SortStableFunc(out, func(a, b *attachedClient) int {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 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {185 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
171 return id186 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 return out190 return out
176 }191 }
177 192
178-func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {193+func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
179 if entry == nil || ac == nil || entry.core() == nil {194 if entry == nil || ac == nil || entry.core() == nil {
180 return false195 return false
181 }196 }
@@ -183,7 +198,7 @@ func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) boo
183 return ok198 return ok
184 }199 }
185 200
186-func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {201+func attachmentRegistered(entry *session, ac *attachedClient) bool {
187 if entry == nil || ac == nil || entry.core() == nil {202 if entry == nil || ac == nil || entry.core() == nil {
188 return false203 return false
189 }204 }
@@ -213,16 +228,6 @@ func (s *session) attachmentViewsTabLocked(tb *tab) bool {
213 return false228 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 func (s *session) snapshotAttachments() []*attachedClient {231 func (s *session) snapshotAttachments() []*attachedClient {
227 if s == nil {232 if s == nil {
228 return nil233 return nil
@@ -232,6 +237,18 @@ func (s *session) snapshotAttachments() []*attachedClient {
232 return s.snapshotAttachmentsLocked()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 // repairAttachmentViewLocked validates stable targets and chooses the nearest252 // repairAttachmentViewLocked validates stable targets and chooses the nearest
236 // surviving target when a tab or pane was removed. Caller holds s.mu.253 // surviving target when a tab or pane was removed. Caller holds s.mu.
237 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {254 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -279,10 +296,12 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
279 } else {296 } else {
280 view.paneID = ""297 view.paneID = ""
281 }298 }
299+ targetTab.mu.Unlock()
282 if view.windowRows <= 0 {300 if view.windowRows <= 0 {
301+ targetTab.mu.Lock()
283 view.windowRows = targetTab.size.Rows302 view.windowRows = targetTab.size.Rows
303+ targetTab.mu.Unlock()
284 }304 }
285- targetTab.mu.Unlock()
286 if view.windowTop < 0 {305 if view.windowTop < 0 {
287 view.windowTop = 0306 view.windowTop = 0
288 }307 }
@@ -301,19 +320,20 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
301 if s == nil || ac == nil {320 if s == nil || ac == nil {
302 return false321 return false
303 }322 }
323+ before := ac.viewSnapshot()
304 s.mu.Lock()324 s.mu.Lock()
305- defer s.mu.Unlock()
306 if _, ok := s.attachments[ac]; !ok {325 if _, ok := s.attachments[ac]; !ok {
326+ s.mu.Unlock()
307 return false327 return false
308 }328 }
309- before := ac.viewSnapshot()
310 after := s.repairAttachmentViewLocked(ac, before)329 after := s.repairAttachmentViewLocked(ac, before)
311 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 {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 return false332 return false
313 }333 }
314 after.revision++334 after.revision++
315- ac.publishView(after)335+ s.mu.Unlock()
316- return true336+ return ac.publishViewIfCurrent(before, after)
317 }337 }
318 338
319 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab339 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
@@ -347,17 +367,13 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
347 367
348 // invalidateViewsLocked orders all per-attachment invalidations behind the368 // invalidateViewsLocked orders all per-attachment invalidations behind the
349 // shared session mutation. Caller holds s.mu and dispatchMu.369 // shared session mutation. Caller holds s.mu and dispatchMu.
350-func (s *session) invalidateViewsLocked() []viewInvalidation {370+func (s *session) invalidateViewsLocked() {
351- attachments := s.snapshotAttachmentsLocked()371+ for _, ac := range s.snapshotAttachmentsLocked() {
352- invalidations := make([]viewInvalidation, 0, len(attachments))
353- for _, ac := range attachments {
354 view := ac.viewSnapshot()372 view := ac.viewSnapshot()
355 repaired := s.repairAttachmentViewLocked(ac, view)373 repaired := s.repairAttachmentViewLocked(ac, view)
356 repaired.revision++374 repaired.revision++
357 ac.publishView(repaired)375 ac.publishView(repaired)
358- invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision})
359 }376 }
360- return invalidations
361 }377 }
362 378
363 // runMutation is the one admission boundary for shared session mutations.379 // runMutation is the one admission boundary for shared session mutations.
@@ -378,23 +394,31 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
378 if s == nil || ac == nil {394 if s == nil || ac == nil {
379 return nil395 return nil
380 }396 }
381- s.mu.Lock()397+ for {
382- defer s.mu.Unlock()398+ view := ac.viewSnapshot()
383- if _, ok := s.attachments[ac]; !ok {399+ s.mu.Lock()
384- return nil400+ if _, ok := s.attachments[ac]; !ok {
385- }401+ s.mu.Unlock()
386- view := ac.viewSnapshot()402+ return nil
387- repaired := s.repairAttachmentViewLocked(ac, view)403+ }
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 {404+ repaired := s.repairAttachmentViewLocked(ac, view)
389- repaired.revision++405+ var target *tab
390- ac.publishView(repaired)406+ for _, tb := range s.tabs {
391- }407+ if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
392- for _, tb := range s.tabs {408+ target = tb
393- if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {409+ break
394- return tb410+ }
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 // paneForAttachment resolves both stable IDs and repairs a removed pane before424 // 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 // repairAttachmentViews repairs all live attachment targets after a shared603 // repairAttachmentViews repairs all live attachment targets after a shared
580 // topology mutation.604 // topology mutation.
581-func (s *session) repairAttachmentViews() []viewInvalidation {605+func (s *session) repairAttachmentViews() {
582 if s == nil {606 if s == nil {
583- return nil607+ return
584 }608 }
585- var invalidations []viewInvalidation
586 _ = s.runMutation(func() error {609 _ = s.runMutation(func() error {
587 s.mu.Lock()610 s.mu.Lock()
588- invalidations = s.invalidateViewsLocked()611+ s.invalidateViewsLocked()
589 s.mu.Unlock()612 s.mu.Unlock()
590 return nil613 return nil
591 })614 })
592- return invalidations
593 }615 }
594 616
595 // repairAttachmentViewsLocked repairs all live attachment targets after a tab617 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
596-// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at618+// or pane mutation. It is intended to run at the same mutation linearization
597-// the same mutation linearization point as the shared membership change.619+// point as the shared membership change.
598-func (s *session) repairAttachmentViewsLocked() []viewInvalidation {620+func (s *session) repairAttachmentViewsLocked() {
599 if s == nil {621 if s == nil {
600- return nil622+ 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 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)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 for i, want := range []byte{1, 2, 3} {190 for i, want := range []byte{1, 2, 3} {
191- if invalidations[i].attachment.clientID[0] != want {191+ if ordered[i].clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.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 func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }31 func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
32 func (*blockedAttachmentTransport) Close() error { return nil }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 func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {100 func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
35 pty, releasePTY := newBlockingPTY(t)101 pty, releasePTY := newBlockingPTY(t)
36 defer releasePTY()102 defer releasePTY()
@@ -76,6 +142,83 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
76 require.Greater(t, second.output.currentEpoch(), secondEpoch)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 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {222 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80 pty, releasePTY := newBlockingPTY(t)223 pty, releasePTY := newBlockingPTY(t)
81 defer releasePTY()224 defer releasePTY()
M internal/usecase/daemon/attachment_transition.go +4 -4
@@ -20,8 +20,8 @@ type attachmentLifecycleFence struct {
20 }20 }
21 21
22 type attachmentTransitionRequest struct {22 type attachmentTransitionRequest struct {
23- source attachmentSession23+ source *session
24- target attachmentSession24+ target *session
25 next *attachedClient25 next *attachedClient
26 expectedTransport transportSnapshot26 expectedTransport transportSnapshot
27 sourceToken *attachmentConnectionToken27 sourceToken *attachmentConnectionToken
@@ -44,7 +44,7 @@ type attachmentTransitionRequest struct {
44 attachmentEffectsFrozen bool44 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 return token.sess == source && token.ac == req.next &&48 return token.sess == source && token.ac == req.next &&
49 token.generation == req.next.connectionGeneration.Load() &&49 token.generation == req.next.connectionGeneration.Load() &&
50 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
@@ -54,7 +54,7 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
54 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a54 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a
55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56 // handoff check for client-originated navigation and lifecycle mutations.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 if token.sess != source || token.ac != req.next ||58 if token.sess != source || token.ac != req.next ||
59 token.generation != req.next.connectionGeneration.Load() ||59 token.generation != req.next.connectionGeneration.Load() ||
60 token.sess == nil || token.ac.currentAttachmentSession() != source ||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 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)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 return transitionSourceTabCurrentForRequestLocked(source, expected, false)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 if expected == nil {58 if expected == nil {
59 return true59 return true
60 }60 }
61- local, ok := localSession(source)61+ if source == nil || !containsTab(source.tabs, expected) {
62- if !ok || !containsTab(local.tabs, expected) {
63 return false62 return false
64 }63 }
65 expected.mu.Lock()64 expected.mu.Lock()
@@ -81,7 +80,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
81 80
82 type attachmentPublication struct {81 type attachmentPublication struct {
83 req attachmentTransitionRequest82 req attachmentTransitionRequest
84- source attachmentSession83+ source *session
85 targetCoordinator *renderCoordinator84 targetCoordinator *renderCoordinator
86 sourceCoordinator *renderCoordinator85 sourceCoordinator *renderCoordinator
87 releaseCoordinators func()86 releaseCoordinators func()
@@ -92,22 +91,21 @@ func (p *attachmentPublication) unlockCoordinators() {
92 p.releaseCoordinators()91 p.releaseCoordinators()
93 }92 }
94 93
95-func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {94+func attachmentLifecycleCurrentLocked(entry *session, fence *attachmentLifecycleFence) bool {
96 if fence == nil {95 if fence == nil {
97 return true96 return true
98 }97 }
99- target, ok := localSession(entry)98+ if entry == nil {
100- if !ok {
101 return false99 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 return false102 return false
105 }103 }
106- if fence.checkIncarnation && target.incarnation != fence.incarnation {104+ if fence.checkIncarnation && entry.incarnation != fence.incarnation {
107 return false105 return false
108 }106 }
109- return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&107+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(entry.tabs) &&
110- domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID108+ domain.TabStableID(entry.tabs[fence.tabIndex].stableID) == fence.tabID
111 }109 }
112 110
113 // validateAttachmentTransitionPrelocked performs every fallible check before111 // validateAttachmentTransitionPrelocked performs every fallible check before
@@ -174,18 +172,12 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
174 // shared environment state as part of the same publication.172 // shared environment state as part of the same publication.
175 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {173 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
176 req := publication.req174 req := publication.req
177- if req.activateTargetTab {175+ if req.activateTargetTab && req.target != nil {
178- if target, ok := localSession(req.target); ok {176+ req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
179- target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
180- }
181 }177 }
182- if req.copySourceEnvironment {178+ if req.copySourceEnvironment && publication.source != nil && req.target != nil {
183- source, sourceOK := localSession(publication.source)179+ req.target.terminal = publication.source.terminal
184- target, targetOK := localSession(req.target)180+ req.target.env = copyEnvironment(publication.source.env)
185- if sourceOK && targetOK {
186- target.terminal = source.terminal
187- target.env = copyEnvironment(source.env)
188- }
189 }181 }
190 return req.preserveAttachment182 return req.preserveAttachment
191 }183 }
@@ -259,9 +251,12 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
259 }251 }
260 252
261 // lockAttachmentSessions gives every two-session transition one stable order.253 // lockAttachmentSessions gives every two-session transition one stable order.
262-func lockAttachmentSessions(a, b attachmentSession) func() {254+func lockAttachmentSessions(a, b *session) func() {
263- aCore := attachmentSessionCore(a)255+ if a == nil || b == nil {
264- bCore := attachmentSessionCore(b)256+ return func() {}
257+ }
258+ aCore := a.core()
259+ bCore := b.core()
265 if aCore == nil || bCore == nil {260 if aCore == nil || bCore == nil {
266 return func() {}261 return func() {}
267 }262 }
@@ -283,7 +278,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
283 278
284 // lockAttachmentCoordinators follows the same immutable session-ID order as279 // lockAttachmentCoordinators follows the same immutable session-ID order as
285 // lockAttachmentSessions. Callers already hold ordered session locks.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 if aCoordinator == nil && bCoordinator == nil {282 if aCoordinator == nil && bCoordinator == nil {
288 return func() {}283 return func() {}
289 }284 }
M internal/usecase/daemon/attention.go +4 -4
@@ -90,8 +90,8 @@ type attentionTarget struct {
90 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {90 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
91 d.mu.Lock()91 d.mu.Lock()
92 sessions := make([]*session, 0, len(d.sessions))92 sessions := make([]*session, 0, len(d.sessions))
93- for _, entry := range d.sessions {93+ for _, sess := range d.sessions {
94- if sess, ok := localSession(entry); ok && sess != current {94+ if sess != nil && sess != current {
95 sessions = append(sessions, sess)95 sessions = append(sessions, sess)
96 }96 }
97 }97 }
@@ -137,7 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137 137
138 func (d *Daemon) repaintAllAttachedClients() {138 func (d *Daemon) repaintAllAttachedClients() {
139 d.mu.Lock()139 d.mu.Lock()
140- sessions := localSessionsSnapshot(d.sessions)140+ sessions := sessionsSnapshot(d.sessions)
141 d.mu.Unlock()141 d.mu.Unlock()
142 142
143 for _, sess := range sessions {143 for _, sess := range sessions {
@@ -245,7 +245,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
245 245
246 func (d *Daemon) anyAttention() bool {246 func (d *Daemon) anyAttention() bool {
247 d.mu.Lock()247 d.mu.Lock()
248- sessions := localSessionsSnapshot(d.sessions)248+ sessions := sessionsSnapshot(d.sessions)
249 d.mu.Unlock()249 d.mu.Unlock()
250 250
251 for _, sess := range sessions {251 for _, sess := range sessions {
M internal/usecase/daemon/bar_refresh.go +2 -3
@@ -196,7 +196,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
196 func (d *Daemon) sessionsSnapshot() []*session {196 func (d *Daemon) sessionsSnapshot() []*session {
197 d.mu.Lock()197 d.mu.Lock()
198 defer d.mu.Unlock()198 defer d.mu.Unlock()
199- return localSessionsSnapshot(d.sessions)199+ return sessionsSnapshot(d.sessions)
200 }200 }
201 201
202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
@@ -390,8 +390,7 @@ func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command
390 return390 return
391 }391 }
392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393- var scriptErr *barScriptError393+ if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
394- if errors.As(err, &scriptErr) {
395 attrs = append(attrs, "exit_code", scriptErr.exitCode)394 attrs = append(attrs, "exit_code", scriptErr.exitCode)
396 if scriptErr.stderr != "" {395 if scriptErr.stderr != "" {
397 attrs = append(attrs, "stderr", scriptErr.stderr)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 d := newBarRefreshTestDaemon(r, 60*time.Second)219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220 sess := newBarRefreshTestSession()220 sess := newBarRefreshTestSession()
221 sess.registerAttachment(&attachedClient{size: domain.Size{Cols: 80, Rows: 24}})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 // Verifies refreshBarScriptsAllSessions iterates every live session and224 // Verifies refreshBarScriptsAllSessions iterates every live session and
225 // dispatches a run for each. This does not exercise force specifically: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 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
42 // connectionGeneration is the wire-facing capability generation. attachmentEffects is42 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
43- // the linearization gate for every attachment-bound observable operation. An attachment43+ // the linearization gate for every attachment-bound observable operation. A role
44 // transition freezes and drains this gate before changing either generation44 // transition freezes and drains this gate before changing either generation
45 // or the session/coordinator registries.45 // or the session/coordinator registries.
46 connectionGeneration atomic.Uint6446 connectionGeneration atomic.Uint64
@@ -73,7 +73,7 @@ type attachedClient struct {
73 // tabs and panes without changing shared session ownership.73 // tabs and panes without changing shared session ownership.
74 viewMu sync.Mutex74 viewMu sync.Mutex
75 view attachmentView75 view attachmentView
76- sess Guarded[attachmentSession]76+ sess Guarded[*session]
77 mouseScan mouse.Scanner77 mouseScan mouse.Scanner
78 themeMu sync.Mutex78 themeMu sync.Mutex
79 clientTheme themeui.Theme79 clientTheme themeui.Theme
@@ -82,7 +82,7 @@ type attachedClient struct {
82 renderStages renderStageHooks // optional render and handoff observability hooks82 renderStages renderStageHooks // optional render and handoff observability hooks
83 // previousSession is guarded independently. It is retained through temporary83 // previousSession is guarded independently. It is retained through temporary
84 // setSession(nil) hand-offs and cleared only on terminal teardown.84 // setSession(nil) hand-offs and cleared only on terminal teardown.
85- previousSession Guarded[attachmentSession]85+ previousSession Guarded[*session]
86 linkMu sync.Mutex86 linkMu sync.Mutex
87 sendMu sync.Mutex87 sendMu sync.Mutex
88 // routeCreatedSession marks a session created by this attachment's route.88 // routeCreatedSession marks a session created by this attachment's route.
@@ -141,15 +141,14 @@ func (ac *attachedClient) initOverlays() {
141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })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 // currentSession narrows the attachment to its owning local session.146 // currentSession narrows the attachment to its owning local session.
147 func (ac *attachedClient) currentSession() *session {147 func (ac *attachedClient) currentSession() *session {
148- sess, _ := localSession(ac.currentAttachmentSession())148+ return ac.currentAttachmentSession()
149- return sess
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 func (ac *attachedClient) clearPreviousSession() {153 func (ac *attachedClient) clearPreviousSession() {
155 if ac != nil {154 if ac != nil {
@@ -558,13 +557,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
558 // attachCoordinator installs the coordinator lease for an attachment. Session557 // attachCoordinator installs the coordinator lease for an attachment. Session
559 // membership remains independent, so installing one attachment never displaces558 // membership remains independent, so installing one attachment never displaces
560 // another.559 // another.
561-func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {560+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
562- rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)561+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
563 cleanup.finish()562 cleanup.finish()
564 return rc563 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 rc := d.ensureRenderCoordinator(sess)567 rc := d.ensureRenderCoordinator(sess)
569 if current != nil {568 if current != nil {
570 rc.attachWithReadiness(current, ready)569 rc.attachWithReadiness(current, ready)
@@ -588,8 +587,8 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
588 }587 }
589 588
590 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an589 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
591-// exact attachment implementation. The caller holds entry.core().mu.590+// exact session. The caller holds entry.core().mu.
592-func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {591+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
593 if rc := attachmentRenderCoordinator(entry); rc != nil {592 if rc := attachmentRenderCoordinator(entry); rc != nil {
594 return rc593 return rc
595 }594 }
@@ -604,7 +603,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
604 // The session snapshot is deterministic. Each paint revalidates the603 // The session snapshot is deterministic. Each paint revalidates the
605 // attachment's membership and transport fence after that snapshot, so604 // attachment's membership and transport fence after that snapshot, so
606 // a detached peer cannot stop remaining attachments receiving output.605 // a detached peer cannot stop remaining attachments receiving output.
607- attachments := snapshotAttachmentSession(entry)606+ attachments := entry.snapshotAttachments()
608 for _, attachment := range attachments {607 for _, attachment := range attachments {
609 if !rc.wakeCurrent(w) {608 if !rc.wakeCurrent(w) {
610 return609 return
@@ -614,27 +613,33 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
614 continue613 continue
615 }614 }
616 // Pane damage is session-shared; every fan-out attachment needs a615 // Pane damage is session-shared; every fan-out attachment needs a
617- // complete frame because any peer may capture it first. A handshake616+ // fresh capture because any peer may acknowledge the damage first.
618- // target is the one synchronous exception: its first paint must finish617+ // Each attachment owns its send lock, so a blocked peer cannot stall
619- // before the handshake releases its lifecycle gate.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 fanoutReset := w.reset || len(attachments) > 1621 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 if w.lease != nil && w.lease.attachment == attachment {627 if w.lease != nil && w.lease.attachment == attachment {
622- d.paint(entry, attachment, fanoutReset, lease)628+ paint()
623 } else if len(attachments) > 1 {629 } else if len(attachments) > 1 {
624- go d.paint(entry, attachment, fanoutReset, lease)630+ go paint()
625-
626 } else {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 installAttachmentRenderCoordinator(entry, rc)643 installAttachmentRenderCoordinator(entry, rc)
639 return rc644 return rc
640 }645 }
@@ -684,11 +689,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
684 token.ac.captureFrames = nil689 token.ac.captureFrames = nil
685 token.ac.sendMu.Unlock()690 token.ac.sendMu.Unlock()
686 }691 }
687- sess, ok := localSession(token.sess)692+ if token.sess == nil {
688- if !ok {
689 return false693 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 return true696 return true
693 }697 }
694 698
@@ -752,8 +756,8 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
752 }756 }
753 clientTheme := themeFromMessage(msg)757 clientTheme := themeFromMessage(msg)
754 token.ac.setClientTheme(clientTheme)758 token.ac.setClientTheme(clientTheme)
755- sess, ok := localSession(token.sess)759+ sess := token.sess
756- if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {760+ if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
757 return false761 return false
758 }762 }
759 if !token.attachmentEffectCurrent() {763 if !token.attachmentEffectCurrent() {
@@ -799,8 +803,8 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
799 ac.pipelineScratch = composeCacheInput{}803 ac.pipelineScratch = composeCacheInput{}
800 ac.sendMu.Unlock()804 ac.sendMu.Unlock()
801 805
802- sess, ok := localSession(token.sess)806+ sess := token.sess
803- if !ok {807+ if sess == nil {
804 return false808 return false
805 }809 }
806 rc := sess.renderCoordinator()810 rc := sess.renderCoordinator()
@@ -847,12 +851,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
847 d.notices.routingMu.Unlock()851 d.notices.routingMu.Unlock()
848 return852 return
849 }853 }
850- sess, ok := localSession(token.sess)854+ if token.sess == nil {
851- if !ok {
852 d.notices.routingMu.Unlock()855 d.notices.routingMu.Unlock()
853 return856 return
854 }857 }
855- repaint := d.mutateClientNotice(sess, token.ac, notice)858+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
856 d.notices.routingMu.Unlock()859 d.notices.routingMu.Unlock()
857 if repaint && token.attachmentEffectCurrent() {860 if repaint && token.attachmentEffectCurrent() {
858 d.repaintForNotice(token.ac)861 d.repaintForNotice(token.ac)
M internal/usecase/daemon/client_frame_routing.go +5 -9
@@ -13,7 +13,7 @@ const connectionSnapshotAttempts = 8
13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a13 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14 // handoff lands between those reads. The attachment gate still performs the final14 // handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.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 for range connectionSnapshotAttempts {17 for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
@@ -54,11 +54,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
57- local, ok := localSession(sess)57+ d.clientGone(sess, ac, tr, false)
58- if !ok {
59- return
60- }
61- d.clientGone(local, ac, tr, false)
62 current := ac.currentAttachmentSession()58 current := ac.currentAttachmentSession()
63 if current == sess || !ac.currentTransportIs(tr) || current == nil {59 if current == sess || !ac.currentTransportIs(tr) || current == nil {
64 return60 return
@@ -180,8 +176,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
180 result.Text = "attached commands cannot override their active session target"176 result.Text = "attached commands cannot override their active session target"
181 return result177 return result
182 }178 }
183- sess, ok := localSession(token.sess)179+ sess := token.sess
184- if !ok || !token.attachmentEffectCurrent() {180+ if sess == nil || !token.attachmentEffectCurrent() {
185 result.Code = ports.ErrNoSuchTarget181 result.Code = ports.ErrNoSuchTarget
186 result.Text = "attached session is no longer active"182 result.Text = "attached session is no longer active"
187 return result183 return result
@@ -221,7 +217,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
221 return result217 return result
222 }218 }
223 219
224-// resetOutput rebases only the exact proxied attachment and transport admitted220+// resetOutput rebases only the exact attachment and transport admitted
225 // by the frame router. sendMu serializes the rebase with every output send;221 // by the frame router. sendMu serializes the rebase with every output send;
226 // transport revalidation under that lock rejects a link replaced while waiting.222 // transport revalidation under that lock rejects a link replaced while waiting.
227 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {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 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 token.effect.End()
40- sess, ok := localSession(token.sess)40+ sess := token.sess
41- if !ok {41+ if sess == nil {
42 return false42 return false
43 }43 }
44 var parkingToken uint6444 var parkingToken uint64
@@ -126,8 +126,8 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
126 if failed == nil || failed != token.transport.transport {126 if failed == nil || failed != token.transport.transport {
127 return127 return
128 }128 }
129- sess, ok := localSession(token.sess)129+ sess := token.sess
130- if !ok {130+ if sess == nil {
131 return131 return
132 }132 }
133 parkingToken := d.markParkingInFlight(sess, token.ac)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 if err != nil {89 if err != nil {
90 return "", err90 return "", err
91 }91 }
92- sess, ok := localSession(token.sess)92+ sess := token.sess
93- if !ok {93+ if sess == nil {
94 _ = os.Remove(path)94 _ = os.Remove(path)
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
96 }96 }
@@ -135,8 +135,8 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135 }135 }
136 136
137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess, ok := localSession(token.sess)138+ sess := token.sess
139- if !ok || !token.attachmentEffectCurrent() {139+ if sess == nil || !token.attachmentEffectCurrent() {
140 return140 return
141 }141 }
142 d.injectClipboardPathToTarget(sess, path, &token)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 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {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 token := sess.attachmentToken(ac, tr)207 token := sess.attachmentToken(ac, tr)
208 token.lease = rc.attachmentLease(ac)208 token.lease = rc.attachmentLease(ac)
209 ac.publishAttachmentCapability(token)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 func (d *Daemon) reapplyThemeAllSessions() {337 func (d *Daemon) reapplyThemeAllSessions() {
338 d.mu.Lock()338 d.mu.Lock()
339- sessions := localSessionsSnapshot(d.sessions)339+ sessions := sessionsSnapshot(d.sessions)
340 d.mu.Unlock()340 d.mu.Unlock()
341 341
342 for _, sess := range sessions {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 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
187 }187 }
188 var match *session188 var match *session
189- for _, entry := range d.sessions {189+ for _, sess := range d.sessions {
190- sess, ok := localSession(entry)190+ if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
191- if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
192 continue191 continue
193 }192 }
194 if match != nil {193 if match != nil {
@@ -201,7 +200,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
201 }200 }
202 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"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 if len(locals) == 0 {204 if len(locals) == 0 {
206 return nil, ports.ErrNoSuchTarget, "no live sessions"205 return nil, ports.ErrNoSuchTarget, "no live sessions"
207 }206 }
@@ -658,7 +657,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
658 Active bool `json:"active"`657 Active bool `json:"active"`
659 }658 }
660 e.d.mu.Lock()659 e.d.mu.Lock()
661- sessions := localSessionsSnapshot(e.d.sessions)660+ sessions := sessionsSnapshot(e.d.sessions)
662 e.d.mu.Unlock()661 e.d.mu.Unlock()
663 snaps := make([]sessionView, 0, len(sessions))662 snaps := make([]sessionView, 0, len(sessions))
664 for _, sess := range sessions {663 for _, sess := range sessions {
@@ -695,7 +694,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
695 return "", command.ErrInvalidArguments694 return "", command.ErrInvalidArguments
696 }695 }
697 e.d.mu.Lock()696 e.d.mu.Lock()
698- sessions := localSessionsSnapshot(e.d.sessions)697+ sessions := sessionsSnapshot(e.d.sessions)
699 e.d.mu.Unlock()698 e.d.mu.Unlock()
700 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))699 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
701 // Stopped sessions live in d.stopped and are intentionally not in this listing.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 type Daemon struct {71 type Daemon struct {
72 mu sync.Mutex72 mu sync.Mutex
73- sessions map[domain.SessionID]attachmentSession73+ sessions map[domain.SessionID]*session
74 stopped map[string]stoppedSession74 stopped map[string]stoppedSession
75 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}76 creating map[string]struct{}
@@ -144,7 +144,7 @@ type Daemon struct {
144 afterResizeCommitSendLocked func()144 afterResizeCommitSendLocked func()
145 // afterConnectionSessionSnapshot is a deterministic test seam after the145 // afterConnectionSessionSnapshot is a deterministic test seam after the
146 // connection loop reads its current session and before it captures that role.146 // connection loop reads its current session and before it captures that role.
147- afterConnectionSessionSnapshot func(attachmentSession)147+ afterConnectionSessionSnapshot func(*session)
148 // afterAttachmentFrameDispatch is a deterministic test seam after the148 // afterAttachmentFrameDispatch is a deterministic test seam after the
149 // connection loop snapshots a token and before a decoded frame takes effect.149 // connection loop snapshots a token and before a decoded frame takes effect.
150 afterAttachmentFrameDispatch func(attachmentConnectionToken)150 afterAttachmentFrameDispatch func(attachmentConnectionToken)
@@ -586,7 +586,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
586 }586 }
587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
588 d := &Daemon{588 d := &Daemon{
589- sessions: make(map[domain.SessionID]attachmentSession),589+ sessions: make(map[domain.SessionID]*session),
590 stopped: make(map[string]stoppedSession),590 stopped: make(map[string]stoppedSession),
591 creating: make(map[string]struct{}),591 creating: make(map[string]struct{}),
592 parked: make(map[uint64]*parkedAttachment),592 parked: make(map[uint64]*parkedAttachment),
@@ -914,7 +914,7 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
914 914
915 // registerSessionLocked publishes an exact attachment-session identity. Caller915 // registerSessionLocked publishes an exact attachment-session identity. Caller
916 // holds d.mu.916 // holds d.mu.
917-func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {917+func (d *Daemon) registerSessionLocked(entry *session) bool {
918 if entry == nil {918 if entry == nil {
919 return false919 return false
920 }920 }
@@ -928,7 +928,7 @@ func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
928 928
929 // unregisterSessionLocked removes only the exact registered identity. Caller929 // unregisterSessionLocked removes only the exact registered identity. Caller
930 // holds d.mu.930 // holds d.mu.
931-func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {931+func (d *Daemon) unregisterSessionLocked(entry *session) bool {
932 if entry == nil {932 if entry == nil {
933 return false933 return false
934 }934 }
@@ -941,7 +941,7 @@ func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
941 }941 }
942 942
943 func (d *Daemon) sessionsSnapshotLocked() []*session {943 func (d *Daemon) sessionsSnapshotLocked() []*session {
944- return localSessionsSnapshot(d.sessions)944+ return sessionsSnapshot(d.sessions)
945 }945 }
946 946
947 // handleConn reads the first frame off a fresh connection and routes it. A947 // 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 d.mu.Lock()1001 d.mu.Lock()
1002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))1002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
1003 liveNames := make(map[string]struct{}, len(d.sessions))1003 liveNames := make(map[string]struct{}, len(d.sessions))
1004- for _, entry := range d.sessions {1004+ for _, s := range d.sessions {
1005- s, ok := localSession(entry)1005+ if s == nil {
1006- if !ok {
1007 continue1006 continue
1008 }1007 }
1009 s.mu.Lock()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 func firstSession(d *Daemon) *session {297 func firstSession(d *Daemon) *session {
298 d.mu.Lock()298 d.mu.Lock()
299 defer d.mu.Unlock()299 defer d.mu.Unlock()
300- for _, entry := range d.sessions {300+ for _, s := range d.sessions {
301- if s, ok := localSession(entry); ok {301+ if s != nil {
302 return s302 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 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384 if h.connectionToken.ac != nil {384 if h.connectionToken.ac != nil {
385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- sess, _ := localSession(h.connectionToken.sess)386+ return h.connectionToken.sess, effect, false
387- return sess, effect, false
388 }387 }
389 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
390 if h.d.afterDelayedKeyEffectAttempt != nil {389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -398,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
398 token.effect = effect397 token.effect = effect
399 h.d.afterAttachmentEffectAdmitted(token)398 h.d.afterAttachmentEffectAdmitted(token)
400 }399 }
401- sess, ok := localSession(h.connectionToken.sess)400+ if h.connectionToken.sess == nil {
402- if !ok {
403 effect.End()401 effect.End()
404 return nil, nil, false402 return nil, nil, false
405 }403 }
406- return sess, effect, true404+ return h.connectionToken.sess, effect, true
407 }405 }
408 sess := h.ac.currentSession()406 sess := h.ac.currentSession()
409 if sess == nil {407 if sess == nil {
M internal/usecase/daemon/move.go +2 -2
@@ -180,8 +180,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
180 if d == nil {180 if d == nil {
181 return nil181 return nil
182 }182 }
183- sess, ok := localSession(d.sessions[locator.ID])183+ sess := d.sessions[locator.ID]
184- if !ok || sess.incarnation != locator.Incarnation {184+ if sess == nil || sess.incarnation != locator.Incarnation {
185 return nil185 return nil
186 }186 }
187 return sess187 return sess
M internal/usecase/daemon/move_cleanup_test.go +2 -2
@@ -35,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
35 defer releaseDestination()35 defer releaseDestination()
36 36
37 d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)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 sourceTab := source.tabs[0]39 sourceTab := source.tabs[0]
40 sourceTab.stableID = "source-tab"40 sourceTab.stableID = "source-tab"
41 moved := sourceTab.focusedPane()41 moved := sourceTab.focusedPane()
@@ -117,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
117 sourceTab.stableID = "source-tab"117 sourceTab.stableID = "source-tab"
118 remainingTab.stableID = "remaining-tab"118 remainingTab.stableID = "remaining-tab"
119 moved := sourceTab.focusedPane()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 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}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 destinationTab := destination.tabs[0]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 sourceTab := source.tabs[0]19 sourceTab := source.tabs[0]
20 sourceTab.stableID = "source-tab"20 sourceTab.stableID = "source-tab"
21 moved := sourceTab.focusedPane()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 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}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 destinationTab := destination.tabs[0]25 destinationTab := destination.tabs[0]
@@ -184,7 +184,7 @@ func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
184 184
185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187- d.attachCoordinator(sess, current, true)187+ d.attachCoordinator(sess, nil, current, true)
188 otherTransport := &closeTrackingTransport{}188 otherTransport := &closeTrackingTransport{}
189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190 other.initOverlays()190 other.initOverlays()
@@ -192,7 +192,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
192 sess.mu.Lock()192 sess.mu.Lock()
193 require.True(t, sess.registerAttachmentLocked(other))193 require.True(t, sess.registerAttachmentLocked(other))
194 sess.mu.Unlock()194 sess.mu.Unlock()
195- d.attachCoordinator(sess, other, true)195+ d.attachCoordinator(sess, nil, other, true)
196 196
197 currentToken := sess.attachmentToken(current, current.transport())197 currentToken := sess.attachmentToken(current, current.transport())
198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)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 if d == nil || source.Session.ID == "" || source.TabID == "" {10 if d == nil || source.Session.ID == "" || source.TabID == "" {
11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12 }12 }
13- sess, ok := localSession(d.sessionByID(source.Session.ID))13+ sess := d.sessionByID(source.Session.ID)
14- if !ok || sess.incarnation != source.Session.Incarnation {14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
@@ -120,8 +120,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
120 120
121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122 d.mu.Lock()122 d.mu.Lock()
123- sess, ok := localSession(d.sessions[target.Session])123+ sess := d.sessions[target.Session]
124- if !ok {124+ if sess == nil {
125 d.mu.Unlock()125 d.mu.Unlock()
126 return nil, nil126 return nil, nil
127 }127 }
M internal/usecase/daemon/move_tab_test.go +2 -2
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153 moved := source.tabs[0]153 moved := source.tabs[0]
154 moved.stableID = "moved-tab"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 destination := addMoveTabTestSession(d, "destination", "destination-tab")156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158 moved.mu.Lock()158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453 453
454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())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 source.incarnation = domain.IncarnationID{4}457 source.incarnation = domain.IncarnationID{4}
458 moved := source.tabs[0]458 moved := source.tabs[0]
459 moved.stableID = "moved-tab"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 d.sessions[target.id] = target97 d.sessions[target.id] = target
98 d.mu.Unlock()98 d.mu.Unlock()
99 99
100- rc := d.attachCoordinator(source, old, true)100+ rc := d.attachCoordinator(source, nil, old, true)
101 token := source.attachmentToken(old, old.transport())101 token := source.attachmentToken(old, old.transport())
102 token.lease = rc.attachmentLease(old)102 token.lease = rc.attachmentLease(old)
103 old.publishAttachmentCapability(token)103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155- rc := d.attachCoordinator(source, old, true)155+ rc := d.attachCoordinator(source, nil, old, true)
156 token := source.attachmentToken(old, old.transport())156 token := source.attachmentToken(old, old.transport())
157 token.lease = rc.attachmentLease(old)157 token.lease = rc.attachmentLease(old)
158 old.publishAttachmentCapability(token)158 old.publishAttachmentCapability(token)
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +1 -1
@@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
47 d.sessions[target.id] = target47 d.sessions[target.id] = target
48 d.mu.Unlock()48 d.mu.Unlock()
49 49
50- rc := d.attachCoordinator(source, ac, true)50+ rc := d.attachCoordinator(source, nil, ac, true)
51 token := source.attachmentToken(ac, ac.transport())51 token := source.attachmentToken(ac, ac.transport())
52 token.lease = rc.attachmentLease(ac)52 token.lease = rc.attachmentLease(ac)
53 ac.publishAttachmentCapability(token)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 current.mu.Unlock()85 current.mu.Unlock()
86 86
87 d.mu.Lock()87 d.mu.Lock()
88- live := localSessionsSnapshot(d.sessions)88+ live := sessionsSnapshot(d.sessions)
89 d.mu.Unlock()89 d.mu.Unlock()
90 90
91 snapshots := make([]sessionOverflowSnapshot, 0, len(live))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 d.mu.Lock()152 d.mu.Lock()
153 _, stopped := d.stopped["bravo"]153 _, stopped := d.stopped["bravo"]
154 var bravoLive bool154 var bravoLive bool
155- for _, entry := range d.sessions {155+ for _, candidate := range d.sessions {
156- candidate, ok := localSession(entry)156+ if candidate == nil {
157- if !ok {
158 continue157 continue
159 }158 }
160 candidate.mu.Lock()159 candidate.mu.Lock()
@@ -322,9 +321,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
322 current := ac.currentSession()321 current := ac.currentSession()
323 owners := make([]*session, 0, 1)322 owners := make([]*session, 0, 1)
324 d.mu.Lock()323 d.mu.Lock()
325- for _, entry := range d.sessions {324+ for _, candidate := range d.sessions {
326- candidate, ok := localSession(entry)325+ if candidate == nil {
327- if !ok {
328 continue326 continue
329 }327 }
330 candidate.mu.Lock()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 // enterNotices opens the notification history overlay from the daemon's full18 // enterNotices opens the notification history overlay from the daemon's full
19 // notice center history, newest first.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 history := d.notices.history()21 history := d.notices.history()
22 now := d.clock.Now()22 now := d.clock.Now()
23 ac.overlays.noticeMu.Lock()23 ac.overlays.noticeMu.Lock()
@@ -221,13 +221,13 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222 // send each chunk, then leave a one-shot status-bar confirmation that the222 // send each chunk, then leave a one-shot status-bar confirmation that the
223 // next repaint clears.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 chunks := scopy.OSC52(noticeYankPayload(n))225 chunks := scopy.OSC52(noticeYankPayload(n))
226 for _, chunk := range chunks {226 for _, chunk := range chunks {
227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228 if err != nil {228 if err != nil {
229- if sess, ok := localSession(entry); ok {229+ if entry != nil {
230- d.detachOnSendError(sess, ac, failed)230+ d.detachOnSendError(entry, ac, failed)
231 } else {231 } else {
232 _ = ac.closeCapturedTransport(failed)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 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
237 }237 }
238 238
239-func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {239+func (d *Daemon) reportAttachmentError(entry *session, err error) {
240- local, _ := localSession(entry)240+ d.reportError(entry, err)
241- d.reportError(local, err)
242 }241 }
243 242
244 // notify records a notice and routes it to whoever can see it. A nil sess means243 // 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 func (d *Daemon) deliverGlobal(n domain.Notification) {299 func (d *Daemon) deliverGlobal(n domain.Notification) {
301 d.mu.Lock()300 d.mu.Lock()
302 d.notices.routingMu.Lock()301 d.notices.routingMu.Lock()
303- sessions := make([]attachmentSession, 0, len(d.sessions))302+ sessions := make([]*session, 0, len(d.sessions))
304 for _, entry := range d.sessions {303 for _, entry := range d.sessions {
305 sessions = append(sessions, entry)304 sessions = append(sessions, entry)
306 }305 }
@@ -311,7 +310,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
311 }310 }
312 targets := make([]*attachedClient, 0, len(sessions))311 targets := make([]*attachedClient, 0, len(sessions))
313 for _, s := range sessions {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 if !d.publishToast(ac, n) {317 if !d.publishToast(ac, n) {
316 continue318 continue
317 }319 }
M internal/usecase/daemon/notify_test.go +1 -1
@@ -52,7 +52,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
52 52
53 func TestMalformedClientNoticeIsIgnored(t *testing.T) {53 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
54 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})54 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
55- d.attachCoordinator(sess, ac, true)55+ d.attachCoordinator(sess, nil, ac, true)
56 tr, ok := ac.tr.(*portsmocks.MockTransport)56 tr, ok := ac.tr.(*portsmocks.MockTransport)
57 require.True(t, ok, "attached client transport must be a MockTransport")57 require.True(t, ok, "attached client transport must be a MockTransport")
58 frames := []ports.Frame{58 frames := []ports.Frame{
M internal/usecase/daemon/output_state_stream.go +6 -3
@@ -71,7 +71,6 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
71 maxOutstanding: uint64(window),71 maxOutstanding: uint64(window),
72 }72 }
73 stream.maxOutstandingAtomic.Store(uint64(window))73 stream.maxOutstandingAtomic.Store(uint64(window))
74- stream.publishOutstanding()
75 return stream74 return stream
76 }75 }
77 76
@@ -287,8 +286,8 @@ func (s *outputStateStream) rebaseLocked() {
287 s.next = 0286 s.next = 0
288 s.acked = 0287 s.acked = 0
289 s.generation++288 s.generation++
290- s.maxOutstandingAtomic.Store(s.maxOutstanding)
291 s.publishOutstanding()289 s.publishOutstanding()
290+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
292 s.renderer.Reset()291 s.renderer.Reset()
293 s.forceSnapshot = true292 s.forceSnapshot = true
294 s.initialized = false293 s.initialized = false
@@ -317,7 +316,11 @@ func (s *outputStateStream) atCapacity() bool {
317 if s == nil {316 if s == nil {
318 return false317 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 func (s *outputStateStream) outstanding() uint64 {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 if entry == nil {150 if entry == nil {
151 return151 return
152 }152 }
153- sess, local := localSession(entry)153+ sess := entry
154 var cmd command.Command154 var cmd command.Command
155 var sessionTarget palette.Result155 var sessionTarget palette.Result
156 var hasSessionTarget bool156 var hasSessionTarget bool
@@ -276,10 +276,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
276 var err error276 var err error
277 if effect != nil {277 if effect != nil {
278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
279- } else if local {
280- err = d.switchToTarget(sess, ac, target)
281 } else {279 } else {
282- err = errAttachmentTransition280+ err = d.switchToTarget(sess, ac, target)
283 }281 }
284 if errors.Is(err, errAttachmentTransition) {282 if errors.Is(err, errAttachmentTransition) {
285 return283 return
@@ -317,12 +315,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317 }315 }
318 return316 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 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"318 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
327 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {319 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
328 return320 return
@@ -401,7 +393,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
401 type paletteExec struct {393 type paletteExec struct {
402 d *Daemon394 d *Daemon
403 sess *session395 sess *session
404- attachment attachmentSession396+ attachment *session
405 ac *attachedClient397 ac *attachedClient
406 recent []recentSession398 recent []recentSession
407 actions daemonActionRunner399 actions daemonActionRunner
@@ -435,7 +427,7 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
435 }427 }
436 428
437 func (e paletteExec) CreateTab() error {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 func (e paletteExec) CreateSession() error {433 func (e paletteExec) CreateSession() error {
@@ -445,11 +437,10 @@ func (e paletteExec) CreateSession() error {
445 }437 }
446 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {438 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
447 if token.ac == nil {439 if token.ac == nil {
448- sess, ok := e.localSession()440+ if e.sess == nil {
449- if !ok {
450 return errAttachmentTransition441 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 return e.d.createSessionAndSwitchForAttachment(token, name)445 return e.d.createSessionAndSwitchForAttachment(token, name)
455 })446 })
@@ -633,14 +624,6 @@ func (e paletteExec) YankLastNotification() error {
633 return nil624 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 func (e paletteExec) JumpRecentSession(rank int) error {627 func (e paletteExec) JumpRecentSession(rank int) error {
645 if rank < 1 || rank > len(e.recent) {628 if rank < 1 || rank > len(e.recent) {
646 return command.ErrInvalidArguments629 return command.ErrInvalidArguments
@@ -656,8 +639,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
656 if e.effect != nil {639 if e.effect != nil {
657 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")640 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
658 } else {641 } else {
659- sess, ok := e.localSession()642+ sess := e.attachment
660- if !ok {643+ if sess == nil {
644+ sess = e.sess
645+ }
646+ if sess == nil {
661 return errAttachmentTransition647 return errAttachmentTransition
662 }648 }
663 err = e.d.switchToTarget(sess, e.ac, picker.Target{Session: target.id, TabIndex: -1})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 compose: func() { fixture.renderCompositions++ },1083 compose: func() { fixture.renderCompositions++ },
1084 emit: func() { fixture.renderEmissions++ },1084 emit: func() { fixture.renderEmissions++ },
1085 }1085 }
1086- d.attachCoordinator(sess, ac, true)1086+ d.attachCoordinator(sess, nil, ac, true)
1087 1087
1088 // Prime the real renderer shadow before measurements. Subsequent paints use1088 // Prime the real renderer shadow before measurements. Subsequent paints use
1089 // actual production diffs rather than the initial full frame.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 // enterPicker preserves the existing navigation entry point. Navigation always51 // enterPicker preserves the existing navigation entry point. Navigation always
52 // publishes its model, including an empty one; only move entry can fail for a52 // publishes its model, including an empty one; only move entry can fail for a
53 // missing destination.53 // missing destination.
54-func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {54+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
55 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})55 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
56 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})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 rt := ac.overlays60 rt := ac.overlays
61 rt.pickerMu.Lock()61 rt.pickerMu.Lock()
62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration62 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,10 +83,13 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
83 83
84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally84 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
85 // knows nothing about picker intent; picker.New owns all destination policy.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 d.mu.Lock()87 d.mu.Lock()
88- sessions := make([]attachmentSession, 0, len(d.sessions))88+ sessions := make([]*session, 0, len(d.sessions))
89 for _, entry := range d.sessions {89 for _, entry := range d.sessions {
90+ if entry == nil {
91+ continue
92+ }
90 sessions = append(sessions, entry)93 sessions = append(sessions, entry)
91 }94 }
92 stopped := make([]stoppedSession, 0, len(d.stopped))95 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -98,14 +101,14 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
98 d.mu.Unlock()101 d.mu.Unlock()
99 102
100 for _, entry := range sessions {103 for _, entry := range sessions {
101- if local, ok := localSession(entry); ok {104+ if entry != nil {
102- d.refreshSessionFocusedTitles(local)105+ d.refreshSessionFocusedTitles(entry)
103 }106 }
104 }107 }
105 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}108 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
106 109
107 type liveSnapshot struct {110 type liveSnapshot struct {
108- entry attachmentSession111+ entry *session
109 view sessionView112 view sessionView
110 }113 }
111 // One snapshot per live session: sorting and view building read the same114 // 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 var current picker.SourceFilter119 var current picker.SourceFilter
117 for _, entry := range sessions {120 for _, entry := range sessions {
118 if entry == cur && ac != nil {121 if entry == cur && ac != nil {
119- if local, ok := localSession(entry); ok {122+ entry.repairAttachmentView(ac)
120- local.repairAttachmentView(ac)
121- }
122 }123 }
123 snap := entry.snapshotView(opts)124 snap := entry.snapshotView(opts)
124 if entry == cur {125 if entry == cur {
125- if _, ok := localSession(entry); ok && ac != nil {126+ if ac != nil {
126 view := ac.viewSnapshot()127 view := ac.viewSnapshot()
127 if view.tabID != "" {128 if view.tabID != "" {
128 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}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 return views, current198 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 views, attachedCurrent := d.pickerViews(cur, ac)202 views, attachedCurrent := d.pickerViews(cur, ac)
202 if intent == pickerNavigate && current == (picker.SourceFilter{}) {203 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
203 current = attachedCurrent204 current = attachedCurrent
@@ -346,11 +347,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
346 var err error347 var err error
347 if len(effects) != 0 && effects[0] != nil {348 if len(effects) != 0 && effects[0] != nil {
348 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")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 } else {350 } else {
353- err = errAttachmentTransition351+ d.closePicker(ac)
352+ err = d.switchToTarget(sess, ac, target)
354 }353 }
355 if errors.Is(err, errAttachmentTransition) {354 if errors.Is(err, errAttachmentTransition) {
356 return355 return
@@ -409,7 +408,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
409 if targetSess == ac.currentAttachmentSession() {408 if targetSess == ac.currentAttachmentSession() {
410 return409 return
411 }410 }
412- rc := d.attachCoordinator(targetSess, nil, false)411+ rc := d.attachCoordinator(targetSess, nil, nil, false)
413 rc.subscribePreviewFor(ac, generation, func(renderWake) {412 rc.subscribePreviewFor(ac, generation, func(renderWake) {
414 if pickerPreviewCurrent(ac, targetSess, next, generation) {413 if pickerPreviewCurrent(ac, targetSess, next, generation) {
415 if owner := ac.currentAttachmentSession(); owner != nil {414 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -479,9 +478,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
479 }478 }
480 d.mu.Lock()479 d.mu.Lock()
481 var clients []*attachedClient480 var clients []*attachedClient
482- for _, entry := range d.sessions {481+ for _, sess := range d.sessions {
483- sess, ok := localSession(entry)482+ if sess == nil {
484- if !ok {
485 continue483 continue
486 }484 }
487 sess.mu.Lock()485 sess.mu.Lock()
@@ -556,7 +554,7 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
556 return *left == *right554 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 d.mu.Lock()558 d.mu.Lock()
561 defer d.mu.Unlock()559 defer d.mu.Unlock()
562 return d.sessions[id]560 return d.sessions[id]
@@ -605,9 +603,9 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
605 return nil603 return nil
606 }604 }
607 d.mu.Lock()605 d.mu.Lock()
608- targetSess, ok := localSession(d.sessions[target.Session])606+ targetSess := d.sessions[target.Session]
609 d.mu.Unlock()607 d.mu.Unlock()
610- if !ok {608+ if targetSess == nil {
611 if !token.attachmentCurrent() {609 if !token.attachmentCurrent() {
612 return nil610 return nil
613 }611 }
@@ -667,11 +665,10 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
667 }665 }
668 token.effect.bindActionEnd(d, action)666 token.effect.bindActionEnd(d, action)
669 token.effect.End()667 token.effect.End()
670- sess, ok := localSession(token.sess)668+ if token.sess == nil {
671- if !ok {
672 return errAttachmentTransition669 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 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the674 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
@@ -755,7 +752,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
755 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)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 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)756 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
760 }757 }
761 d.mu.Unlock()758 d.mu.Unlock()
@@ -1031,9 +1028,9 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
1031 return d.killPickerTarget(target)1028 return d.killPickerTarget(target)
1032 }1029 }
1033 d.mu.Lock()1030 d.mu.Lock()
1034- targetSess, ok := localSession(d.sessions[target.Session])1031+ targetSess := d.sessions[target.Session]
1035 d.mu.Unlock()1032 d.mu.Unlock()
1036- if !ok {1033+ if targetSess == nil {
1037 return nil1034 return nil
1038 }1035 }
1039 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")1036 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -1048,9 +1045,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
1048 return nil1045 return nil
1049 }1046 }
1050 d.mu.Lock()1047 d.mu.Lock()
1051- targetSess, ok := localSession(d.sessions[target.Session])1048+ targetSess := d.sessions[target.Session]
1052 d.mu.Unlock()1049 d.mu.Unlock()
1053- if ok {1050+ if targetSess != nil {
1054 return d.killSession(targetSess, ports.ReasonSessionKilled, true)1051 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1055 }1052 }
1056 return nil1053 return nil
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
1550 current.mu.Lock()1550 current.mu.Lock()
1551 current.registerAttachmentLocked(ac)1551 current.registerAttachmentLocked(ac)
1552 current.mu.Unlock()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 d.enterPicker(current, ac)1555 d.enterPicker(current, ac)
1556 d.handlePickerInput(ac, []byte("k"))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 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}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 d.closePrompt(ac)17 d.closePrompt(ac)
18 ac.overlays.promptMu.Lock()18 ac.overlays.promptMu.Lock()
19 ac.overlays.prompt = promptui.New(title, initial)19 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
23 d.invalidateRender(sess, ac, true, "prompt.go")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 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)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 // recentSessions returns the current session's capped named-session MRU list.18 // recentSessions returns the current session's capped named-session MRU list.
19 // Callers may retain the returned values for the lifetime of an interaction.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 if d == nil {21 if d == nil {
22 return nil22 return nil
23 }23 }
24 d.mu.Lock()24 d.mu.Lock()
25 recent := make([]recentSession, 0, len(d.sessions))25 recent := make([]recentSession, 0, len(d.sessions))
26 for _, sess := range d.sessions {26 for _, sess := range d.sessions {
27- if sess == current {27+ if sess == nil || sess == current {
28 continue28 continue
29 }29 }
30 snap := sess.snapshotView(viewOptions{})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 return false60 return false
61 }61 }
62 d.mu.Lock()62 d.mu.Lock()
63- sessions := localSessionsSnapshot(d.sessions)63+ sessions := sessionsSnapshot(d.sessions)
64 d.mu.Unlock()64 d.mu.Unlock()
65 for _, sess := range sessions {65 for _, sess := range sessions {
66 for _, ac := range sess.snapshotAttachments() {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 // Session-owned PTY preparation remains local; attachment rendering is302 // Session-owned PTY preparation remains local; attachment rendering is
295- // captured through attachmentSession.captureRenderState.303+ // captured through session.captureRenderState.
296- sess, local := localSession(entry)304+ sess := entry
305+ local := sess != nil
297 marks := d.newRuntimeMarkBatch()306 marks := d.newRuntimeMarkBatch()
298 if lease != nil {307 if lease != nil {
299 token := attachmentToken(entry, ac, ac.transport())308 token := attachmentToken(entry, ac, ac.transport())
300 token.lease = lease309 token.lease = lease
301 ticket, admitted := ac.beginAttachmentEffect(token)310 ticket, admitted := ac.beginAttachmentEffect(token)
302 if !admitted {311 if !admitted {
303- return312+ return paintRejected
304 }313 }
305 marks.attachmentEffect = ticket314 marks.attachmentEffect = ticket
306 defer ticket.End()315 defer ticket.End()
@@ -309,7 +318,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
309 if local {318 if local {
310 tb = sess.tabForAttachment(ac)319 tb = sess.tabForAttachment(ac)
311 if tb == nil {320 if tb == nil {
312- return321+ return paintRejected
313 }322 }
314 }323 }
315 324
@@ -322,23 +331,30 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
322 entry.core().mu.Unlock()331 entry.core().mu.Unlock()
323 if !owned || ac.currentAttachmentSession() != entry {332 if !owned || ac.currentAttachmentSession() != entry {
324 ac.sendMu.Unlock()333 ac.sendMu.Unlock()
325- return334+ return paintRejected
326 }335 }
327 if lease != nil {336 if lease != nil {
328 rc := attachmentRenderCoordinator(entry)337 rc := attachmentRenderCoordinator(entry)
329 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {338 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
330 ac.sendMu.Unlock()339 ac.sendMu.Unlock()
331- return340+ return paintRejected
332 }341 }
333 }342 }
334- // Capacity is checked before any destructive capture. The coordinator is343+ // Capacity is checked before any destructive capture. Refresh the atomic
335- // the normal gate, but this ownership check also protects direct resize and344+ // readiness snapshot while sendMu is held so direct test/setup mutations of
336- // test paint paths from consuming damage that cannot be emitted.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 if ac.output != nil && ac.output.atCapacity() {353 if ac.output != nil && ac.output.atCapacity() {
338 // The coordinator owns the blocked interval; this guard only protects354 // The coordinator owns the blocked interval; this guard only protects
339 // direct test/resize paint calls from destructive capture.355 // direct test/resize paint calls from destructive capture.
340 ac.sendMu.Unlock()356 ac.sendMu.Unlock()
341- return357+ return paintBlockedCapacity
342 }358 }
343 // Composition owns attachment sendMu; initialize its lazy overlay state359 // Composition owns attachment sendMu; initialize its lazy overlay state
344 // under that same ownership so concurrent fallback paints cannot observe a360 // 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 endCapture(0, ok)434 endCapture(0, ok)
419 if !ok {435 if !ok {
420 ac.sendMu.Unlock()436 ac.sendMu.Unlock()
421- return437+ return paintRejected
422 }438 }
423 if ac.renderStages.capture != nil {439 if ac.renderStages.capture != nil {
424 ac.renderStages.capture()440 ac.renderStages.capture()
@@ -437,6 +453,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
437 ac.renderStages.compose()453 ac.renderStages.compose()
438 }454 }
439 d.emitFrame(entry, ac, state, composed, &marks)455 d.emitFrame(entry, ac, state, composed, &marks)
456+ return paintEmitted
440 }457 }
441 458
442 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles459 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 // captureRenderState preserves the focused local test seam. Production346 // captureRenderState preserves the focused local test seam. Production
347-// rendering delegates through attachmentSession.captureRenderState.347+// rendering delegates through *session.captureRenderState.
348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349 return captureLocalRenderState(sess, ac, request)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 // invalidations retain only their exact target lease here.39 // invalidations retain only their exact target lease here.
40 pendingShared bool40 pendingShared bool
41 pendingTargets map[*attachedClient]*attachmentLease41 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 // ackBlocked retains the first rejected ACK-capacity probe until the42 // ackBlocked retains the first rejected ACK-capacity probe until the
46 // matching consume or lifecycle closure releases that exact interval.43 // matching consume or lifecycle closure releases that exact interval.
47 ackBlocked *ackBlockedSpan44 ackBlocked *ackBlockedSpan
@@ -316,6 +313,81 @@ func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
316 c.fire(gen, false, false)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 // noteSyncBegin records a synchronized-output batch for its stable pane and391 // noteSyncBegin records a synchronized-output batch for its stable pane and
320 // arms that pane's watchdog. Overlapping pane batches are independent.392 // arms that pane's watchdog. Overlapping pane batches are independent.
321 // markDeadlineDue records a received timer tick before the worker probes ACK393 // markDeadlineDue records a received timer tick before the worker probes ACK
@@ -807,7 +879,7 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
807 // invalidateRender is the sole producer fan-in. In tests and transitional879 // invalidateRender is the sole producer fan-in. In tests and transitional
808 // headless paths without an attached coordinator it retains the old private880 // headless paths without an attached coordinator it retains the old private
809 // compositor; attached sessions always schedule through their coordinator.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 if rc := attachmentRenderCoordinator(entry); rc != nil {883 if rc := attachmentRenderCoordinator(entry); rc != nil {
812 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}884 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}
813 if rc.invalidateForAttachment(ac, invalidation) {885 if rc.invalidateForAttachment(ac, invalidation) {
@@ -832,7 +904,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
832 // invalidateRenderNow publishes through the coordinator but immediately904 // invalidateRenderNow publishes through the coordinator but immediately
833 // flushes the wake when the client can accept state. Attach uses this path so905 // flushes the wake when the client can accept state. Attach uses this path so
834 // the required first full frame never depends on a debounce timer.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 if rc := attachmentRenderCoordinator(entry); rc != nil {908 if rc := attachmentRenderCoordinator(entry); rc != nil {
837 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {909 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
838 rc.fireCurrent(false)910 rc.fireCurrent(false)
@@ -880,8 +952,8 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
880 }952 }
881 953
882 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {954 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883- if c.ackReadyFor != nil {955+ if c.opts.ackReadyFor != nil {
884- return c.ackReadyFor(ac)956+ return c.opts.ackReadyFor(ac)
885 }957 }
886 if c.opts.ackReady != nil {958 if c.opts.ackReady != nil {
887 return c.opts.ackReady()959 return c.opts.ackReady()
@@ -1013,7 +1085,11 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1013 if len(leases) == 0 && legacyOutput {1085 if len(leases) == 0 && legacyOutput {
1014 allDelivered = true1086 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 if syncLease != nil {1093 if syncLease != nil {
1018 if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {1094 if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {
1019 w.lease = syncLease1095 w.lease = syncLease
@@ -1043,11 +1119,12 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
1043 }1119 }
1044 1120
1045 if outputReady {1121 if outputReady {
1046- for ac := range attachmentLeases {1122+ for ac, lease := range attachmentLeases {
1047 if c.delivered == nil {1123 if c.delivered == nil {
1048 c.delivered = make(map[*attachedClient]uint64)1124 c.delivered = make(map[*attachedClient]uint64)
1049 }1125 }
1050 c.delivered[ac] = c.pendingGeneration1126 c.delivered[ac] = c.pendingGeneration
1127+ lease.deliveredGeneration = c.pendingGeneration
1051 }1128 }
1052 }1129 }
1053 if len(leases) != 0 {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 // attachmentRenderCoordinator returns the coordinator installed for the exact16 // attachmentRenderCoordinator returns the coordinator installed for the exact
17 // attachment-session identity.17 // attachment-session identity.
18-func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {18+func attachmentRenderCoordinator(entry *session) *renderCoordinator {
19 if entry == nil || entry.core() == nil {19 if entry == nil || entry.core() == nil {
20 return nil20 return nil
21 }21 }
@@ -24,7 +24,7 @@ func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
24 24
25 // installAttachmentRenderCoordinator publishes rc for the exact25 // installAttachmentRenderCoordinator publishes rc for the exact
26 // attachment-session identity.26 // attachment-session identity.
27-func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {27+func installAttachmentRenderCoordinator(entry *session, rc *renderCoordinator) {
28 if entry == nil || entry.core() == nil {28 if entry == nil || entry.core() == nil {
29 return29 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 t.Helper()195 t.Helper()
196 wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")196 wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
197 wake.attachmentLeases = nil197 wake.attachmentLeases = nil
198+ wake.generation = 0
198 return wake199 return wake
199 }200 }
200 201
@@ -404,12 +405,11 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
404 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {405 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
405 slow, healthy := &attachedClient{}, &attachedClient{}406 slow, healthy := &attachedClient{}, &attachedClient{}
406 var slowReady atomic.Bool407 var slowReady atomic.Bool
407- slowReady.Store(false)
408 wakes := make(chan renderWake, 2)408 wakes := make(chan renderWake, 2)
409 rc := newRenderCoordinator(renderCoordinatorOptions{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 rc.attach(slow)413 rc.attach(slow)
414 rc.attach(healthy)414 rc.attach(healthy)
415 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})415 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
@@ -1403,7 +1403,7 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
1403 }1403 }
1404 1404
1405 func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {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 for _, transition := range []struct {1407 for _, transition := range []struct {
1408 name string1408 name string
1409 run func(*renderCoordinator, *attachedClient)1409 run func(*renderCoordinator, *attachedClient)
M internal/usecase/daemon/render_coordinator_types.go +14 -4
@@ -51,6 +51,9 @@ type renderWake struct {
51 reset bool51 reset bool
52 urgent bool52 urgent bool
53 coalesced int53 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 // lease is optional coordinator-owned lifecycle safety for an attachment57 // lease is optional coordinator-owned lifecycle safety for an attachment
55 // specific wake. Shared wakes leave it nil and snapshot live attachment58 // specific wake. Shared wakes leave it nil and snapshot live attachment
56 // leases at dispatch time; each paint then revalidates its exact connection59 // leases at dispatch time; each paint then revalidates its exact connection
@@ -68,8 +71,12 @@ type attachmentLease struct {
68 attachment *attachedClient71 attachment *attachedClient
69 ready bool72 ready bool
70 active bool73 active bool
71- // A lease is an immutable callback capability. Its lifecycle bits are74+ // deliveredGeneration records the latest wake that reached this lease. It
72- // changed only under renderCoordinator.mu; callbacks revalidate it at each75+ // 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 // short effect boundary and never retain coordinator ownership while routing80 // short effect boundary and never retain coordinator ownership while routing
74 // arbitrary handlers.81 // arbitrary handlers.
75 }82 }
@@ -95,8 +102,11 @@ type renderCoordinatorOptions struct {
95 // wake composes a coalesced render request.102 // wake composes a coalesced render request.
96 wake func(renderWake)103 wake func(renderWake)
97 // ackReady reports whether the attachment may compose another output104 // ackReady reports whether the attachment may compose another output
98- // state (the outputStateStream window has capacity).105+ // state (the outputStateStream window has capacity). It is retained for
99- ackReady func() bool106+ // 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 // syncRenderable reports whether a pane's synchronized batch currently110 // syncRenderable reports whether a pane's synchronized batch currently
101 // contributes to the attached composition. It must not acquire c.mu.111 // contributes to the attached composition. It must not acquire c.mu.
102 syncRenderable func(*pane) bool112 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 // Prepare failures now schedule recovery through the coordinator;134 // Prepare failures now schedule recovery through the coordinator;
135 // retain the failed transaction here until this test explicitly retries.135 // retain the failed transaction here until this test explicitly retries.
136 d.clock = newNoticeClock()136 d.clock = newNoticeClock()
137- d.attachCoordinator(sess, ac, true)137+ d.attachCoordinator(sess, nil, ac, true)
138 healthy := ac.transport()138 healthy := ac.transport()
139 p := sess.tabs[0].focusedPane()139 p := sess.tabs[0].focusedPane()
140 140
@@ -170,7 +170,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
171 clock := newNoticeClock()171 clock := newNoticeClock()
172 d, sess, ac, _ := newNoticeFixture(t, clock)172 d, sess, ac, _ := newNoticeFixture(t, clock)
173- rc := d.attachCoordinator(sess, ac, true)173+ rc := d.attachCoordinator(sess, nil, ac, true)
174 var producers []string174 var producers []string
175 rc.opts.onInvalidate = func(inv renderInvalidation) {175 rc.opts.onInvalidate = func(inv renderInvalidation) {
176 // Both the notice repaint and the explicit recovery must re-enter only176 // 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 // emitFrame is the sole side-effecting half of the pipeline. The caller holds429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430 // sendMu for the complete capture/compose/emit transaction.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 var ownedMarks runtimeMarkBatch432 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch433 var marks *runtimeMarkBatch
434 if len(batches) != 0 {434 if len(batches) != 0 {
@@ -480,17 +480,15 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
480 // Without a coordinator reportError repaints synchronously. Suppress only480 // Without a coordinator reportError repaints synchronously. Suppress only
481 // that nested notice repaint; leave the guard before returning so a later,481 // that nested notice repaint; leave the guard before returning so a later,
482 // independent failed transaction can still notify the user.482 // independent failed transaction can still notify the user.
483- if sess, ok := localSession(entry); ok {483+ if entry.renderCoordinator() == nil {
484- if sess.renderCoordinator() == nil {484+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
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)
490 return true485 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 // Notices are session-scoped, while the attachment repaint still needs492 // Notices are session-scoped, while the attachment repaint still needs
495 // to redraw its chrome after a failed transaction.493 // to redraw its chrome after a failed transaction.
496 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")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 // admission first. Detachment freezes the gate and therefore cannot mutate561 // admission first. Detachment freezes the gate and therefore cannot mutate
564 // ownership until any enclosing admitted operation has also ended.562 // ownership until any enclosing admitted operation has also ended.
565 if marks.attachmentEffect == nil {563 if marks.attachmentEffect == nil {
566- if sess, ok := localSession(entry); ok {564+ d.detachOnSendError(entry, ac, sendTr)
567- d.detachOnSendError(sess, ac, sendTr)
568- }
569 } else {565 } else {
570 // Capture the exact admitted capability, including its coordinator566 // Capture the exact admitted capability, including its coordinator
571 // lease, before End permits a new attachment publication. Reserve567 // 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 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314 clock := newCoordinatorMockClock(t, 8)314 clock := newCoordinatorMockClock(t, 8)
315 d.clock = clock.clock315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, ac, true)316+ rc := d.attachCoordinator(sess, nil, ac, true)
317 317
318 d.sessWg.Add(1)318 d.sessWg.Add(1)
319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350 clock := newCoordinatorMockClock(t, 8)350 clock := newCoordinatorMockClock(t, 8)
351 d.clock = clock.clock351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, ac, true)352+ rc := d.attachCoordinator(sess, nil, ac, true)
353 353
354 d.sessWg.Add(2)354 d.sessWg.Add(2)
355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386 clock := newCoordinatorMockClock(t, 8)386 clock := newCoordinatorMockClock(t, 8)
387 d.clock = clock.clock387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, targetClient, true)388+ rc := d.attachCoordinator(target, nil, targetClient, true)
389 389
390 // A viewer in a different session is previewing the headless target.390 // A viewer in a different session is previewing the headless target.
391 viewer := &attachedClient{}391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430 clock := newCoordinatorMockClock(t, 8)430 clock := newCoordinatorMockClock(t, 8)
431 d.clock = clock.clock431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, detached, true)432+ rc := d.attachCoordinator(target, nil, detached, true)
433 433
434 secondTransport, secondSends := newCapturingTransport(t)434 secondTransport, secondSends := newCapturingTransport(t)
435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470 d, target, client, _ := newManualSessionWithPTYs(t, pty)470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471 clock := newCoordinatorMockClock(t, 8)471 clock := newCoordinatorMockClock(t, 8)
472 d.clock = clock.clock472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, client, true)473+ rc := d.attachCoordinator(target, nil, client, true)
474 wakes := make(chan renderWake, 1)474 wakes := make(chan renderWake, 1)
475 rc.opts.wake = func(w renderWake) { wakes <- w }475 rc.opts.wake = func(w renderWake) { wakes <- w }
476 target.mu.Lock()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 type timerSupervisor struct{ workers sync.WaitGroup }66 type timerSupervisor struct{ workers sync.WaitGroup }
67 67
68 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {68 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69- s.workers.Add(1)69+ s.startTaskLocked(func() {
70- go func() {
71- defer s.workers.Done()
72 defer close(token.done)70 defer close(token.done)
73 select {71 select {
74 case <-timerC:72 case <-timerC:
75 fire()73 fire()
76 case <-token.cancel: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 d.mu.Lock()316 d.mu.Lock()
317 defer d.mu.Unlock()317 defer d.mu.Unlock()
318 token := ac.resumeClaimToken318 token := ac.resumeClaimToken
319- if token == 0 {
320- return true
321- }
322 parked := d.parked[token]319 parked := d.parked[token]
323- if parked == nil || parked.ac != ac || !parked.claimed {320+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
324- return false321+ return token == 0
325 }322 }
326 delete(d.parked, token)323 delete(d.parked, token)
327 parked.claimed = false324 parked.claimed = false
@@ -612,7 +609,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612 // mandatory first paint cannot be blocked by ACKs that died with the link.609 // mandatory first paint cannot be blocked by ACKs that died with the link.
613 ac.rebaseOutput()610 ac.rebaseOutput()
614 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))611 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
615- ac.output.syncCapacityLocked()612+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
616 ac.replaceTransport(tr)613 ac.replaceTransport(tr)
617 ac.size = sz614 ac.size = sz
618 ac.resumeToken = d.nextResumeTokenLocked()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 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection876 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
877-// when the attachment is no longer the exact active or snatched owner, while877+// when the attachment is no longer the exact registered owner, while
878 // still advertising for both live roles.878 // still advertising for both live roles.
879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
880 pty, release := newBlockingPTY(t)880 pty, release := newBlockingPTY(t)
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
69 69
70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {70 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())71 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, ac, true)72+ rc := d.attachCoordinator(sess, nil, ac, true)
73 oldLease := rc.attachmentLease(ac)73 oldLease := rc.attachmentLease(ac)
74 require.NotNil(t, oldLease)74 require.NotNil(t, oldLease)
75 75
M internal/usecase/daemon/session.go +18 -27
@@ -155,7 +155,7 @@ type tab struct {
155 // what it has actually seen). sendMu serialises the two senders — the render155 // what it has actually seen). sendMu serialises the two senders — the render
156 // coordinator and the connection handler — so the transport's single-writer156 // 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 if d == nil || entry == nil || entry.core() == nil {159 if d == nil || entry == nil || entry.core() == nil {
160 return160 return
161 }161 }
@@ -170,15 +170,11 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170 break170 break
171 }171 }
172 }172 }
173- sess, ok := localSession(entry)173+ entry.mu.Lock()
174- if !ok {174+ if !entry.ephemeral {
175- return175+ d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
176- }
177- sess.mu.Lock()
178- if !sess.ephemeral {
179- d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
180 }176 }
181- sess.mu.Unlock()177+ entry.mu.Unlock()
182 }178 }
183 179
184 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {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 expectedTransport: token.transport, sourceToken: &token, action: "create-session",470 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475 copySourceEnvironment: true, ready: true,471 copySourceEnvironment: true, ready: true,
476 createTargetLocked: func() (*session, error) {472 createTargetLocked: func() (*session, error) {
477- source, ok := localSession(token.sess)473+ source := token.sess
478- if !ok {474+ if source == nil {
479 return nil, errAttachmentTransition475 return nil, errAttachmentTransition
480 }476 }
481 if d.closing {477 if d.closing {
@@ -500,8 +496,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500 }496 }
501 497
502 d.touchMRU(created)498 d.touchMRU(created)
503- if source, ok := localSession(token.sess); ok {499+ if token.sess != nil {
504- token.ac.recordPreviousSession(source)500+ token.ac.recordPreviousSession(token.sess)
505 }501 }
506 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)502 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
507 d.deferAttachmentTransitionCleanups(transition)503 d.deferAttachmentTransitionCleanups(transition)
@@ -513,7 +509,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
513 return d.createTabForAttachment(sess, nil, sz)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 sess.mu.Lock()513 sess.mu.Lock()
518 name := sess.name514 name := sess.name
519 cwd := sess.cwd515 cwd := sess.cwd
@@ -521,7 +517,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
521 env := copyEnvironment(sess.env)517 env := copyEnvironment(sess.env)
522 attachments := sess.snapshotAttachmentsLocked()518 attachments := sess.snapshotAttachmentsLocked()
523 sess.mu.Unlock()519 sess.mu.Unlock()
524- tbSize := contentSize(sz)520+ tbSize := contentSize(sess.fullViewportSize())
525 tabStableID, paneStableID, err := d.newTabPaneStableIDs()521 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
526 if err != nil {522 if err != nil {
527 return err523 return err
@@ -774,10 +770,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
774 // own an independent connection lifecycle and detach through the same exact770 // own an independent connection lifecycle and detach through the same exact
775 // transport/generation fence.771 // transport/generation fence.
776 if token.lease == nil {772 if token.lease == nil {
777- if sess, ok := localSession(token.sess); ok {773+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
778- return d.detachIfCurrentTransport(sess, token.ac, token.transport)
779- }
780- return false
781 }774 }
782 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)775 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
783 defer frozen.unfreeze()776 defer frozen.unfreeze()
@@ -1152,9 +1145,8 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1152 if token.sess == nil || token.ac == nil || token.transport.transport == nil {1145 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1153 return sessionKillParticipants{}, false1146 return sessionKillParticipants{}, false
1154 }1147 }
1155- var ok bool1148+ snapshot.source = token.sess
1156- snapshot.source, ok = localSession(token.sess)1149+ if snapshot.source == nil {
1157- if !ok {
1158 return sessionKillParticipants{}, false1150 return sessionKillParticipants{}, false
1159 }1151 }
1160 snapshot.sourceToken = &admission.token1152 snapshot.sourceToken = &admission.token
@@ -1641,8 +1633,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1641 // d.mu.1633 // d.mu.
1642 func (d *Daemon) allocEphemeralNameLocked() string {1634 func (d *Daemon) allocEphemeralNameLocked() string {
1643 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))1635 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1644- for _, entry := range d.sessions {1636+ for _, s := range d.sessions {
1645- if s, ok := localSession(entry); ok {1637+ if s != nil {
1646 used[s.name] = struct{}{}1638 used[s.name] = struct{}{}
1647 }1639 }
1648 }1640 }
@@ -1658,9 +1650,8 @@ func (d *Daemon) allocEphemeralNameLocked() string {
1658 }1650 }
1659 1651
1660 func (d *Daemon) findByNameLocked(name string) *session {1652 func (d *Daemon) findByNameLocked(name string) *session {
1661- for _, entry := range d.sessions {1653+ for _, s := range d.sessions {
1662- s, ok := localSession(entry)1654+ if s != nil && s.name == name {
1663- if ok && s.name == name {
1664 return s1655 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 // clearPreviousSessionIf clears target only if it has not been replaced since46 // clearPreviousSessionIf clears target only if it has not been replaced since
47 // the caller observed it. This preserves a concurrent successful hand-off.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 if ac == nil {49 if ac == nil {
50 return50 return
51 }51 }
52- ac.previousSession.With(func(previous *attachmentSession) {52+ ac.previousSession.With(func(previous **session) {
53 if *previous == target {53 if *previous == target {
54 *previous = nil54 *previous = nil
55 }55 }
@@ -59,7 +59,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
59 // recordPreviousSession is called only after a cross-session hand-off has59 // recordPreviousSession is called only after a cross-session hand-off has
60 // committed. Keeping this after the commit makes failed/displaced switches60 // committed. Keeping this after the commit makes failed/displaced switches
61 // leave the toggle pair intact.61 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {62+func (ac *attachedClient) recordPreviousSession(origin *session) {
63 if ac != nil && origin != nil {63 if ac != nil && origin != nil {
64 ac.previousSession.Set(origin)64 ac.previousSession.Set(origin)
65 }65 }
M internal/usecase/daemon/session_core.go +6 -23
@@ -7,16 +7,6 @@ import (
7 "github.com/bnema/vev/internal/domain"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 // sessionCore is the state shared by every attachment-capable session. It is10 // sessionCore is the state shared by every attachment-capable session. It is
21 // embedded as the first field of session so the existing s.mu selector remains11 // embedded as the first field of session so the existing s.mu selector remains
22 // the one lock guarding both attachment membership and local tab state.12 // the one lock guarding both attachment membership and local tab state.
@@ -60,19 +50,12 @@ func (s *session) validTargetTabLocked(tabIndex int) bool {
60 return tabIndex < len(s.tabs)50 return tabIndex < len(s.tabs)
61 }51 }
62 52
63-// localSession narrows an attachment entry for operations that own local PTYs,53+func sessionsSnapshot(entries map[domain.SessionID]*session) []*session {
64-// tabs, snapshots, or durable lifecycle state.54+ sessions := make([]*session, 0, len(entries))
65-func localSession(entry attachmentSession) (*session, bool) {55+ for _, sess := range entries {
66- sess, ok := entry.(*session)56+ if sess != nil {
67- return sess, ok && sess != nil57+ sessions = append(sessions, sess)
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)
75 }58 }
76 }59 }
77- return locals60+ 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 core := sess.core()31 core := sess.core()
32 require.Same(t, &sess.sessionCore, core)32 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
34 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")33 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
35 34
36 core.mu.Lock()35 core.mu.Lock()
@@ -113,7 +112,7 @@ func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
113 }112 }
114 113
115 func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {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 first := &session{}116 first := &session{}
118 first.id = domain.SessionID("same")117 first.id = domain.SessionID("same")
119 second := &session{}118 second := &session{}
@@ -146,9 +145,8 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
146 second.core().mu.Unlock()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 t.Helper()149 t.Helper()
151- sess, ok := localSession(entry)150+ require.NotNil(t, entry)
152- require.True(t, ok)151+ return entry
153- return sess
154 }152 }
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
39 require.True(t, ok)39 require.True(t, ok)
40 pty.EXPECT().Close().Return(nil).Once()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 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))43 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
44 require.Equal(t, []string{"delete incarnation"}, repository.calls)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 store, state := newMockStore(t)16 store, state := newMockStore(t)
17 WithStore(t, store)(d)17 WithStore(t, store)(d)
18 sess := newSnapshotTestSession(t, "old", false, "/work")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 sess.mu.Lock()20 sess.mu.Lock()
21 record := sess.persistRecordLocked(1)21 record := sess.persistRecordLocked(1)
22 sess.mu.Unlock()22 sess.mu.Unlock()
M internal/usecase/daemon/status.go +8 -15
@@ -240,24 +240,21 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
240 // palette interaction has captured recent-session hints. Normal palette state240 // palette interaction has captured recent-session hints. Normal palette state
241 // continues to use the canonical, live MRU list.241 // continues to use the canonical, live MRU list.
242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
243- var entry attachmentSession243+ var entry *session
244 if cur != nil {244 if cur != nil {
245 entry = cur245 entry = cur
246 }246 }
247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
248 }248 }
249 249
250-// barStateForAttachmentPaletteHints composes daemon-owned chrome for either250+// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
251-// attachment implementation. Bar scripts remain local-session-only because251+// attachment. Bar scripts remain local-session-only because
252 // their existing execution contract depends on local tabs and PTYs.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 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)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 {257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
258- if attachmentSessionCore(cur) == nil {
259- cur = nil
260- }
261 ranked := rankedRecentForHints(hints, recent)258 ranked := rankedRecentForHints(hints, recent)
262 state := barState{statusFeedback: statusFeedback}259 state := barState{statusFeedback: statusFeedback}
263 if d != nil {260 if d != nil {
@@ -268,14 +265,10 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac
268 if d != nil {265 if d != nil {
269 includeTerminalTitle = d.currentTabsConfig().TerminalTitle266 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
270 }267 }
271- if local, ok := localSession(cur); ok {268+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
272- state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273- } else {
274- state.status = cur.statusSegments(includeTerminalTitle)
275- }
276 }269 }
277- if local, ok := localSession(cur); ok && d != nil {270+ if cur != nil && d != nil {
278- state.topRight, state.bottomRight = d.barScriptSnapshot(local)271+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
279 }272 }
280 if ranked != nil {273 if ranked != nil {
281 state.rankedRecent = ranked274 state.rankedRecent = ranked
M internal/usecase/daemon/status_test.go +3 -18
@@ -22,20 +22,6 @@ import (
22 "github.com/bnema/vev/pkg/renderer"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 // stubClock returns timers whose channel never fires, so a scheduler under it25 // stubClock returns timers whose channel never fires, so a scheduler under it
40 // blocks in its debounce loop until the session context is cancelled. Used by26 // 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 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {919 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
934- var nilEntry attachmentSession920+ var nilEntry *session
935 var typedNil *session921 var typedNil *session
936 valid := &session{sessionCore: sessionCore{id: "valid"}}922 valid := &session{sessionCore: sessionCore{id: "valid"}}
937 923
938 for _, tc := range []struct {924 for _, tc := range []struct {
939 name string925 name string
940- a attachmentSession926+ a *session
941- b attachmentSession927+ b *session
942 }{928 }{
943 {name: "nil interfaces", a: nilEntry, b: nilEntry},929 {name: "nil interfaces", a: nilEntry, b: nilEntry},
944 {name: "typed nil", a: typedNil, b: valid},930 {name: "typed nil", a: typedNil, b: valid},
945- {name: "core nil", a: &coreNilAttachment{}, b: valid},
946 } {931 } {
947 t.Run(tc.name, func(t *testing.T) {932 t.Run(tc.name, func(t *testing.T) {
948 require.NotPanics(t, func() {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 if ac == nil {848 if ac == nil {
849 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)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 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)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 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411 observer := &daemonRuntimeObserver{}411 observer := &daemonRuntimeObserver{}
412 d.runtimeObserver = observer412 d.runtimeObserver = observer
413- rc := d.attachCoordinator(sess, ac, true)413+ rc := d.attachCoordinator(sess, nil, ac, true)
414 lease := rc.attachmentLease(ac)414 lease := rc.attachmentLease(ac)
415 var newer uint64415 var newer uint64
416 d.beforeSessionResizePublication = func() {416 d.beforeSessionResizePublication = func() {
@@ -471,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
471 t.Run(tc.name, func(t *testing.T) {471 t.Run(tc.name, func(t *testing.T) {
472 pty := &transactionalResizePTY{}472 pty := &transactionalResizePTY{}
473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474- rc := d.attachCoordinator(sess, ac, true)474+ rc := d.attachCoordinator(sess, nil, ac, true)
475 lease := rc.attachmentLease(ac)475 lease := rc.attachmentLease(ac)
476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477 require.NotZero(t, epoch)477 require.NotZero(t, epoch)
@@ -555,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
555 tb.mu.Unlock()555 tb.mu.Unlock()
556 owner := publishPaneOwner(popup, source, tb, 7)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 lease := rc.attachmentLease(ac)559 lease := rc.attachmentLease(ac)
560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -780,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781 tb.mu.Unlock()781 tb.mu.Unlock()
782 782
783- rc := d.attachCoordinator(sess, ac, true)783+ rc := d.attachCoordinator(sess, nil, ac, true)
784 lease := rc.attachmentLease(ac)784 lease := rc.attachmentLease(ac)
785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786 require.NotZero(t, epoch)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 // One in-flight state makes the second public resize take the ACK-blocked20 // One in-flight state makes the second public resize take the ACK-blocked
21 // path. The real coordinator owns both renders and the ACK wake.21 // path. The real coordinator owns both renders and the ACK wake.
22 ac.output = newOutputStateStream(1)22 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, ac, true)23+ rc := d.attachCoordinator(sess, nil, ac, true)
24 24
25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {25 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
26 t.Fatal("first resize was not accepted")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 func frameWelcome(s *session, ac *attachedClient) ports.Frame {9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10+ s.mu.Lock()
10 w := ports.Welcome{11 w := ports.Welcome{
11 SessionID: string(s.id),12 SessionID: string(s.id),
12 SessionName: s.name,13 SessionName: s.name,
13 Ephemeral: s.ephemeral,14 Ephemeral: s.ephemeral,
14 Capabilities: ports.CapabilityResume,15 Capabilities: ports.CapabilityResume,
15- ResumeToken: ac.resumeToken,
16 }16 }
17+ s.mu.Unlock()
18+ w.ResumeToken = ac.resumeToken
17 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close