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
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively.
67+- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25` (`MsgAttachTarget`). Types `14–15` and `24` remain reserved.
6868 - Version negotiation requires strict equality.
6969 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
@@ -74,6 +74,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
7474 - Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
7575 - Named sessions survive headless and persist across daemon restarts.
7676 - The daemon starts on first use and exits when the last session ends.
77-- Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
78-- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.
79-- Only one client may attach to a session; a new attach replaces the old client.
77+- Each connection has a 15-second handshake budget from connect through the initial committed publication.
78+- Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79+- Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80+- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.
81+- Command requests have a 10-second result deadline and are tracked per connection.
M README.md +4 -4
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
5151
5252 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
5353
54-Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically.
54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.
5555
5656 ## Keys
5757
@@ -73,13 +73,13 @@ The palette does the rest: type a short code (`SPR` split right, `CNT` new tab,
7373 vev attach user@host[:session]
7474 ```
7575
76-SSH bootstraps the connection, then the session runs over UDP and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
76+The client opens a direct connection to the selected remote daemon. UDP is the default: SSH bootstraps an authenticated UDP endpoint, then the session runs over that connection and resumes after sleep or Wi-Fi changes. vev must be installed on the remote. If your firewall only allows SSH, open the UDP range first (default `61000:61023`, override with `VEV_UDP_PORT_RANGE`):
7777
7878 ```sh
7979 sudo ufw allow 61000:61023/udp
8080 ```
8181
82-Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
82+Where UDP is not an option, set `VEV_REMOTE_TRANSPORT=stdio` to keep the direct connection inside SSH; this is an explicit transport choice. Every connection has a 15-second handshake deadline, and each command request has a 10-second deadline. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
8383
8484 List sessions on a known host (OpenSSH resolves aliases from your SSH config):
8585
@@ -119,7 +119,7 @@ vev cmd [-s <source-session>] [--self] move-pane <destination-session> <destinat
119119 vev cmd [-s <source-session>] [--self] move-tab <destination-session>
120120 ```
121121
122-Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its client follows the moved pane or tab, activates the destination, and snatches any active destination client.
122+Use `vev cmd -s <destination-session> list-tabs` to find stable destination tab IDs. Destinations must be live, but may be named or ephemeral; stopped sessions are not eligible. A moved pane is split to the right of the destination tab's focused pane and becomes that tab's internal focus without activating the tab. A moved tab is appended to the destination in the background. If the move empties the source session, its attachment follows the moved pane or tab and activates the destination; existing destination attachments keep their own views.
123123
124124 Moving the final tiled pane out of a tab is rejected while that tab has a floating pane slot; close the floating pane or move the whole tab instead. Named-session persistence is best-effort across a move, not crash-atomic across source and destination snapshots.
125125
M docs/performance.md +69 -185
@@ -1,212 +1,96 @@
11 # Performance measurement
22
3-## S5a transport observability
4-
5-S5a is measurement-only. It records process-local runtime marks and harness-owned
6-end-to-end boundaries without changing rendering, pacing, transport policy, wire
7-bytes, or `ProtocolVersion`. The canonical matrix and workload/topology/
8-transport coverage are in [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
9-`1x4`, `4x1`, `4x4`, and `8x1`, all at `120x40` with 10,000 rows per pane;
10-idle, active/all/inactive output, interactive flood, copy search, resize sweep,
11-snapshot/output/resize, and attach/restore/tab switch; local, SSH stdio, UDP
12-baseline, UDP 25/100 ms, and UDP 0%/1% loss. It has 252 scenarios (4×9×7),
13-with inapplicable cases required to be explicit. The seven transport entries are
14-`local`, `ssh_stdio`, `udp_baseline`, `udp_loss_0pct`, `udp_25ms`, `udp_100ms`,
15-and `udp_loss_1pct`.
16-
17-### Trace schema and clock domains
3+vev measures the current session and attachment stack with process-local runtime
4+marks and harness-owned end-to-end boundaries. Measurement does not alter
5+rendering, pacing, transport policy, wire bytes, or `ProtocolVersion`.
6+
7+## Canonical matrix
8+
9+The canonical matrix is [`testdata/perf/manifest.json`](../testdata/perf/manifest.json):
10+
11+- topologies `1x4`, `4x1`, `4x4`, and `8x1`;
12+- `120x40` terminal content with 10,000 history rows per pane;
13+- idle, active output, all/inactive output, interactive flood, copy search,
14+ resize sweep, snapshot/output/resize, and attach/restore/tab switch;
15+- local, SSH stdio, direct UDP, UDP with 25/100 ms latency, and UDP with 0%/1%
16+ loss.
17+
18+The matrix contains 252 scenarios (4 topologies × 9 workloads × 7
19+transports). Inapplicable scenarios stay present with an explicit reason.
20+Multiple-attachment scenarios measure shared session mutations together with
21+independent attachment views and output streams.
22+
23+A connection handshake is bounded by 15 seconds. A command request is bounded
24+by 10 seconds. These limits apply during measurement as well as normal use.
25+
26+## Trace schema and clocks
1827
1928 Every process JSONL record has exactly these fields:
20-`schema` (uint), `process_id` (string), `component` (string), `scenario`
21-(string), `run`, `sequence`, `request_id`, `epoch` (uint), `kind` (string),
22-`tick` (int64), `bytes`, `fragments`, `retransmits`, `pending` (uint),
23-`ack_rtt_nanos` (int64), and `valid` (bool). Schema 1 is represented by:
29+`schema`, `process_id`, `component`, `scenario`, `run`, `sequence`, `request_id`,
30+`epoch`, `kind`, `tick`, `bytes`, `fragments`, `retransmits`, `pending`,
31+`ack_rtt_nanos`, and `valid`.
32+
33+Schema 1 is represented by:
2434
2535 ```json
2636 {"schema":1,"process_id":"p","component":"daemon","scenario":"s","run":1,"sequence":1,"request_id":1,"epoch":1,"kind":"diff_start","tick":0,"bytes":0,"fragments":0,"retransmits":0,"pending":0,"ack_rtt_nanos":0,"valid":true}
2737 ```
2838
29-`tick` is stamped only by the concrete JSONL observer using its process-local
30-injected monotonic clock; producers supply no timestamp. The harness sets `VEV_PERF_TRACE`,
31-`VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
32-launched role, identifying its exclusive trace path and process/scenario/run
33-mapping. Components in one OS
34-process share that observer. A spawned daemon is deliberately stripped of
35-these variables so it cannot append to a role's trace. The harness clock is a
36-separate monotonic domain and is the sole clock for input-injection → terminal-
37-bytes-successfully-flushed end-to-end samples. Cross-process ticks are never
38-ordered or subtracted; records correlate only by scenario/run/sequence/request/
39-epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false`
40-record explicitly reports bounded-observer loss; spans crossing that gap are
41-excluded, while an unmatched end without such a gap remains an error.
42-
43-The named process-local spans are capture, compose, diff, queue wait,
44-ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end
45-marks are paired within one process and summarized by component/adapter with
46-raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
47-retransmits, pending, ACK RTT) remain separate from duration calculations.
48-
49-### Historical proxy ANSI baseline
50-
51-Before this refactor, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. This parser benchmark remains
53-a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
54-guardrail.
55-
56-The binaries were built from clean, exact commits in temporary clones so their
57-provenance is embedded and checkable:
39+The concrete JSONL observer stamps `tick` with its process-local injected
40+monotonic clock; producers supply no timestamp. Components in one OS process
41+share that observer. The harness owns a separate monotonic clock for the
42+input-injection to successfully-flushed-terminal-bytes boundary. Ticks from
43+different processes or clock domains are never ordered or subtracted. Records
44+correlate through scenario, run, sequence, request, and epoch IDs.
5845
59-```sh
60-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone
61-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c
62-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone
63-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df0
64-(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
65-(cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .)
66-GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone
67-GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d
68-(cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness)
69-go version -m /tmp/vev-proxy-baseline-1f2c802
70-go version -m /tmp/vev-proxy-candidate-2b48abf9
71-go version -m /tmp/vev-perf-harness-d479fe9
72-```
46+Process-local spans cover capture, compose, diff, queue wait, acknowledgement
47+blocking, emit, adapter send, and adapter receive. Bytes, fragments,
48+retransmits, pending work, and acknowledgement RTT remain diagnostics rather
49+than duration inputs. Reports retain raw records, counts, p50/p95/p99, and max
50+where applicable.
7351
74-The baseline benchmark below is a measured median of ten repetitions on
75-Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
76-`1f2c802d0707785cab979134db6a95645250a82c`:
52+## Running the matrix
7753
78-```sh
79-cd /tmp/vev-perf-baseline-clone
80-go test ./internal/usecase/daemon -run '^$' \
81- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
82- | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt
83-```
84-
85-| ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
86-| --- | ---: | ---: | ---: | ---: |
87-| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
88-| 120-column full line | 4,777 | 130 | 1 | 126 |
89-| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
90-| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
91-
92-Keep the ordinary local ANSI renderer as a separate guardrail:
54+Use a clean build and at least a 10-second warmup, a 30-second measured
55+interval, and ten independent repetitions:
9356
9457 ```sh
95-cd /tmp/vev-perf-candidate-2b48abf9-clone
96-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \
97- | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt
58+go build -o /tmp/vev ./
59+rm -rf testdata/perf/results
60+go run ./cmd/vev-perf-harness \
61+ --vev-bin /tmp/vev \
62+ --manifest testdata/perf/manifest.json \
63+ --out testdata/perf/results \
64+ --warmup 10s --duration 30s --repetitions 10
9865 ```
9966
100-The candidate guardrail completed with Go 1.26.5; its ten-repetition output is
101-retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.
67+Each run writes `manifest.json`, `raw-harness.jsonl`, one trace per launched
68+process, `runs.json`, and `summary.json`. The process manifest records process
69+identity, role, scenario/run, clock domain, and its exclusive trace path.
10270
103-### Public-CLI transport runs
71+The harness injects at most one measured event per second. A sample requires
72+both input injection and successful terminal flush inside the measured
73+interval; warmup, straddling, and failed events remain raw diagnostics. Reject
74+results with missing or unmatched boundaries, invalid correlation,
75+nonmonotonic same-process sequence, negative same-domain duration, fewer than
76+10 complete in-interval pairs, invalid denominators, cross-process tick math,
77+less than 30 seconds measured, or fewer than 10 repetitions.
10478
105-The adoption gate requires a 10-second warmup, a 30-second measured interval,
106-and ten independent repetitions for each of seven workloads × five transports
107-(35 scenarios), for both exact baseline and candidate binaries. This exact
108-command template was executed for every scenario (the two 35-scenario sets
109-were run separately to avoid oversubscribing the host):
79+Before a full run, validate the manifest and harness:
11080
11181 ```sh
112-for bin in baseline candidate; do
113- case "$bin" in
114- baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
115- candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
116- esac
117- for workload in active_output all_output inactive_output interactive_flood resize_sweep \
118- snapshot_output_resize attach_restore_tab_switch; do
119- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
120- scenario="1x4-${workload}-${transport}"
121- /tmp/vev-perf-harness-d479fe9 \
122- --vev-bin "$vev_bin" \
123- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
124- --scenario "$scenario" \
125- --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
126- --warmup 10s --duration 30s --repetitions 10
127- done
128- done
129-done
82+jq -e '(.topologies|length)==4 and (.workloads|length)==9 and (.transports|length)==7 and (.scenarios|length)==252' testdata/perf/manifest.json
83+go test ./cmd/vev-perf-harness
13084 ```
13185
132-The measured outputs are retained only under `/tmp/vev-perf-results/`. Each
133-binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a
134-summary. The common completed results below are harness end-to-end medians
135-from `summary.json` (p50/p95, ns):
136-
137-| scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 |
138-| --- | ---: | ---: | ---: | ---: |
139-| 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 |
140-| 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 |
141-| 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 |
142-| 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 |
143-| 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 |
144-| 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 |
145-| 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 |
146-| 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 |
147-| 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 |
148-| 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 |
149-| 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 |
150-| 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 |
151-| 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 |
152-
153-`1x4-active_output-ssh_stdio` now completes for both binaries, so the former
154-trace-correlation blocker is fixed. The remaining failed scenarios are
155-retained as stderr evidence: all five `interactive_flood`, all five
156-`resize_sweep`, all five `snapshot_output_resize`, the four non-local
157-`attach_restore_tab_switch` transports, and the three `udp_loss_1pct`
158-output scenarios. They report terminal-start/readiness failures or missing
159-receive span pairs; they are not converted into samples.
160-
161-The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid
162-A/B summaries, so the 5% gate and adoption cannot be declared. The completed
163-common p50 values happened to stay within ±1.12%, but that is not a gate
164-result and does not substitute for the 22 missing scenarios.
165-
166-`summary.json` provenance was checked for every completed result:
167-`git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with
168-`vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and
169-`vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The
170-complete 252-scenario canonical matrix was not run because the representative
171-gate was incomplete. Its exact command remains:
86+For a bounded local check, add `--scenario 1x4-idle-local`. The harness still
87+validates the complete manifest before selecting that scenario. Do not commit
88+result directories; raw traces and run manifests are the measurement evidence.
17289
173-```sh
174-rm -rf /tmp/vev-perf-results/public-full-baseline
175-/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
176- --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
177- --out /tmp/vev-perf-results/public-full-baseline \
178- --warmup 10s --duration 30s --repetitions 10
179-```
90+## In-process checks
18091
181-Warmup is excluded. During the measured interval the harness injects at most
182-one event per second. At least 10 complete event pairs are required: both input
183-injection and successful terminal flush must fall inside the interval.
184-Straddling or failed events remain raw diagnostics, not samples. Each run must
185-contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, and
186-`summary.json`.
187-
188-`summary.json` records the harness source as `git_sha`, the measured vev binary
189-source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
190-manifests, and JSONL identify scenario, run, process, component, and correlation
191-IDs. Preserve those files with the benchmark output and record
192-`go version -m /tmp/vev-proxy-baseline-1f2c802`,
193-`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
194-`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
195-CPU and allocation evidence comes from Go microbenchmarks and process-local
196-span durations. End-to-end latency, terminal flush, and byte evidence comes
197-from the public-CLI harness. Reset, snapshot, and span counters for structured
198-output are candidate-only until that output exists.
199-
200-Reject results with missing input/flush boundaries, unmatched IDs, bad
201-correlation, nonmonotonic same-process sequence, negative same-domain spans,
202-fewer than 10 complete in-interval event pairs, invalid denominators, any
203-cross-process tick arithmetic, or unmet duration/repetition minima. Raw JSONL
204-and run manifests are evidence; no result alone authorizes an optimization.
205-
206-## Existing in-process smoke checks
207-
208-These commands measure local daemon work only and are not end-to-end transport
209-results:
92+These commands measure local daemon or VT work only, not end-to-end transport
93+latency:
21094
21195 ```sh
21296 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
M docs/remote-resilience.md +53 -27
@@ -1,45 +1,71 @@
11 # Remote resilience
22
3-Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers.
3+Remote attachments connect directly to the selected vev daemon. UDP is the
4+normal carriage: SSH bootstraps an authenticated endpoint, then the attachment
5+carries the vev protocol over UDP. Set `VEV_REMOTE_TRANSPORT=stdio` to use an
6+SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7+the remote host.
48
5-The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state.
9+The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10+panes, a fixed PTY content size, and ordered mutations. Each attachment keeps
11+its own window size, selected tab and pane, copy mode, overlays, render/output
12+acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13+attachment without replacing or changing its peers.
614
7-## Operational model
15+## Connection limits
816
9-- SSH bootstrap starts or finds the remote UDP endpoint.
10-- UDP carries normal session traffic after bootstrap. The remote proxy binds a port in a fixed range, `61000-61023` by default; `VEV_UDP_PORT_RANGE` overrides it (a range, a single port, or `0` for a random ephemeral port).
11-- Named sessions remain recoverable across daemon restarts; ephemeral sessions remain recoverable only while the daemon retains them.
12-- `VEV_REMOTE_TRANSPORT=stdio` keeps all traffic inside SSH when direct UDP is blocked.
17+- A connection handshake has one 15-second deadline from connection start
18+ through Hello, Welcome, and the initial committed publication. The deadline
19+ applies to local and remote connections.
20+- Each command request has a 10-second deadline for its result. A timeout
21+ abandons only that request; it does not complete a later request with a stale
22+ result.
23+- The first valid attachment establishes the session's PTY content size. A
24+ later attachment can resize its own window without resizing shared PTYs.
1325
14-## Recovery states
26+## UDP operation
1527
16-Resilience work uses these user-facing states:
28+- The remote host selects a UDP port in `61000-61023` by default. Set
29+ `VEV_UDP_PORT_RANGE` to a range, one port, or `0` for an ephemeral port.
30+- The UDP carriage authenticates and retransmits protocol frames, preserves
31+ frame order, and reports bounded link-health state without logging payloads,
32+ addresses, keys, or identities.
33+- The attachment reconnects with its resume token after a path change. The
34+ remote session and its PTYs remain in place while the attachment is offline.
1735
18-| State | Meaning |
19-| --- | --- |
20-| `connected` | a complete record or meaningful ACK progress is recent |
21-| `degraded` | authenticated UDP packets still arrive, but complete-record or ACK progress is delayed |
22-| `probing` | authenticated UDP packet contact is absent, so vev probes for path recovery |
23-| `resuming` | a replacement transport is attaching to the same session |
24-| `offline` | authenticated UDP contact remains absent; vev re-runs the SSH bootstrap and re-dials UDP with the resume token |
25-| `expired` | the session or recovery window is no longer available |
36+The raw UDP AEAD key is held only in memory during bootstrap and transport
37+setup. It is not written to durable state. Key buffers are cleared on the
38+reachable handoff paths on a best-effort basis; cipher implementations and
39+other I/O owners may retain internal copies for their lifetime.
2640
27-When a terminal is in raw mode, recovery messages appear as vev status text rather than as bytes written into the remote program's output stream.
41+## Connection states
2842
29-`VEV_REMOTE_TRANSPORT=stdio` is the SSH-stdio fallback: it keeps the session inside authenticated SSH when UDP is blocked or unavailable. It remains available while UDP recovery is attempted.
43+| State | Meaning |
44+| --- | --- |
45+| `connected` | Recent authenticated contact and complete frame progress. |
46+| `degraded` | Authenticated packets arrive, but complete-frame or acknowledgement progress is delayed. |
47+| `probing` | Authenticated contact is absent and the carriage is probing for a recovered path. |
48+| `resuming` | This attachment is opening a replacement connection with its resume token. |
49+| `offline` | The attachment is re-establishing the selected remote transport. |
50+| `expired` | The session or attachment resume window is no longer available. |
3051
31-## UDP transport health diagnostics
52+In raw terminal mode, state changes appear as vev status text rather than bytes
53+written into the PTY's output stream.
3254
33-At debug logging, UDP transport-health lines report only state, elapsed progress ages, and bounded transport counters; they do not include payloads, addresses, keys, or identities. For a real remote attempt, `scripts/debug-remote-attach.sh --udp-health user@host` stores redacted transport-health lines and Linux `ss -u -m` socket-memory counters, then prints the exact collected file paths.
55+## Diagnostics and checks
3456
35-## Manual checks
57+At debug level, transport-health logs contain only state, elapsed progress ages,
58+and bounded counters. `scripts/debug-remote-attach.sh --udp-health user@host`
59+collects redacted health lines and Linux `ss -u -m` socket-memory counters.
3660
37-Useful scenarios for validating resilience behavior:
61+Useful checks are:
3862
3963 - block and unblock the configured UDP port range;
4064 - move between networks or change VPN state;
41-- suspend and resume the client machine;
42-- retry `vev attach user@host:session` for the same named session;
43-- compare logs for UDP silence, probing, SSH bootstrap failure, proxy expiry, and missing sessions.
65+- suspend and resume the attachment machine;
66+- reconnect to the same named session;
67+- verify that a second attachment retains its independent view while the first
68+ reconnects.
4469
45-Keep hostnames, usernames, and local paths out of shared logs and screenshots.
70+Keep hostnames, usernames, keys, and local paths out of shared logs and
71+screenshots.
M docs/terminal.md +7 -1
@@ -8,12 +8,18 @@ When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORT
88
99 On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
1010
11-For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.
11+The attaching client's exported environment is sent in the connection handshake and becomes the session's source of truth for future PTY children, including remote attaches. A later attachment updates that shared environment for children created afterward; already-running processes keep their original environment.
1212
1313 ## VT compatibility
1414
1515 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
1616
17+## Session content size
18+
19+The first valid attachment establishes the session's fixed PTY content size. Each
20+attachment may have a different outer window and viewport; resizing it changes
21+only its attachment-local presentation and does not resize shared PTYs.
22+
1723 ## Recovery
1824
1925 Named-session checkpoints preserve bounded scrollback and a recovery transcript containing the primary viewport followed by the active alternate viewport, when present. Each viewport trims only trailing untouched blank rows; leading, internal, styled, and otherwise touched blank rows remain.
M internal/adapters/sshstdio/transport.go +237 -30
@@ -12,6 +12,7 @@ import (
1212 "fmt"
1313 "io"
1414 "log/slog"
15+ "os"
1516 "os/exec"
1617 "strings"
1718 "sync"
@@ -33,6 +34,18 @@ var (
3334
3435 type closeFunc func() error
3536 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+}
3649
3750 type Option func(*transport)
3851
@@ -57,7 +70,26 @@ func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc
5770 if closeFn == nil {
5871 closeFn = func() error { return nil }
5972 }
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
6193 }
6294
6395 type transport struct {
@@ -65,11 +97,14 @@ type transport struct {
6597 w io.Writer
6698 close closeFunc
6799 eofErr eofErrFunc
100+ readerCloser io.Closer
101+ writerCloser io.Closer
102+ done chan struct{}
68103 observer ports.SerializedRuntimeObserver
69104 operationMu sync.Mutex
70- operationCount int
105+ operationCount [operationKindCount]int
71106 closing bool
72- operationsDone chan struct{}
107+ operationsDone [operationKindCount]chan struct{}
73108 closeOnce sync.Once
74109 closeErr error
75110
@@ -77,6 +112,154 @@ type transport struct {
77112 readBuf []byte
78113 }
79114
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+
80263 func (t *transport) Send(f ports.Frame) error {
81264 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
82265 n := 1 + len(f.Payload)
@@ -136,7 +319,11 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
136319 if t.observer == nil {
137320 return func(bool) {}
138321 }
139- if !t.beginObservedOperation() {
322+ kind := operationSend
323+ if start == ports.RuntimeAdapterReceiveStart {
324+ kind = operationReceive
325+ }
326+ if !t.beginObservedOperation(kind) {
140327 return func(bool) {}
141328 }
142329 correlation := ports.NewRuntimeCorrelation()
@@ -146,7 +333,7 @@ func (t *transport) beginOperation(start ports.RuntimeMarkKind, bytes uint64) fu
146333 end = ports.RuntimeAdapterReceiveEnd
147334 }
148335 return func(valid bool) {
149- defer t.finishObservedOperation()
336+ defer t.finishObservedOperation(kind)
150337 t.observer.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("sshstdio", correlation, end, bytes, valid))
151338 }
152339 }
@@ -163,20 +350,25 @@ func (t *transport) mapEOFError(err error) error {
163350
164351 func (t *transport) Close() error {
165352 t.closeOnce.Do(func() {
166- done := t.beginShutdown()
353+ wait := t.beginShutdown()
167354 // Close owned streams before waiting for in-flight operations. The
168355 // 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.
170359 var writeErr, readErr error
171- if w, ok := t.w.(io.Closer); ok {
172- writeErr = w.Close()
360+ if t.writerCloser != nil {
361+ writeErr = t.writerCloser.Close()
173362 }
174- if r, ok := t.r.(io.Closer); ok {
175- readErr = r.Close()
363+ if t.readerCloser != nil {
364+ readErr = t.readerCloser.Close()
176365 }
177366 closeErr := t.close()
178- if done != nil {
179- <-done
367+ if t.writerCloser != nil && wait.send != nil {
368+ <-wait.send
369+ }
370+ if t.readerCloser != nil && wait.receive != nil {
371+ <-wait.receive
180372 }
181373 switch {
182374 case closeErr != nil:
@@ -190,36 +382,41 @@ func (t *transport) Close() error {
190382 return t.closeErr
191383 }
192384
193-func (t *transport) beginObservedOperation() bool {
385+func (t *transport) beginObservedOperation(kind operationKind) bool {
194386 t.operationMu.Lock()
195387 defer t.operationMu.Unlock()
196388 if t.closing {
197389 return false
198390 }
199- if t.operationCount == 0 {
200- t.operationsDone = make(chan struct{})
391+ if t.operationCount[kind] == 0 {
392+ t.operationsDone[kind] = make(chan struct{})
201393 }
202- t.operationCount++
394+ t.operationCount[kind]++
203395 return true
204396 }
205397
206-func (t *transport) finishObservedOperation() {
398+func (t *transport) finishObservedOperation(kind operationKind) {
207399 t.operationMu.Lock()
208400 defer t.operationMu.Unlock()
209- t.operationCount--
210- if t.operationCount == 0 {
211- close(t.operationsDone)
401+ t.operationCount[kind]--
402+ if t.operationCount[kind] == 0 {
403+ close(t.operationsDone[kind])
212404 }
213405 }
214406
215-func (t *transport) beginShutdown() <-chan struct{} {
407+func (t *transport) beginShutdown() operationWait {
216408 t.operationMu.Lock()
217409 defer t.operationMu.Unlock()
218410 t.closing = true
219- if t.operationCount == 0 {
220- return nil
411+ close(t.done)
412+ var wait operationWait
413+ if t.operationCount[operationSend] != 0 {
414+ wait.send = t.operationsDone[operationSend]
415+ }
416+ if t.operationCount[operationReceive] != 0 {
417+ wait.receive = t.operationsDone[operationReceive]
221418 }
222- return t.operationsDone
419+ return wait
223420 }
224421
225422 type processWaiter struct {
@@ -344,10 +541,11 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
344541 return dialContext(ctx, target, session, logger, opts...)
345542 }
346543
347-// DialContext is like Dial, but the context is propagated to ssh startup so a
348-// canceled attach attempt interrupts the local ssh process. Callers may pass a
349-// logger to record ssh start failures and non-clean exits without logging the
350-// generated command line.
544+// DialContext is like Dial, but the context gates ssh startup. Once the
545+// transport is returned, its Close method owns the subprocess lifetime; the
546+// handshake context must not kill an already-established carriage. Callers may
547+// pass a logger to record ssh start failures and non-clean exits without logging
548+// the generated command line.
351549 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
352550 var log *slog.Logger
353551 if len(logger) > 0 {
@@ -361,7 +559,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
361559 return nil, err
362560 }
363561 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...)
365568 stdin, err := cmd.StdinPipe()
366569 if err != nil {
367570 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
@@ -381,6 +584,10 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
381584
382585 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
383586 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
587+ if err := ctx.Err(); err != nil {
588+ _ = transport.Close()
589+ return nil, err
590+ }
384591 for _, opt := range opts {
385592 if opt != nil {
386593 opt(transport)
M internal/adapters/sshstdio/transport_observability_test.go +78 -1
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "encoding/json"
66 "errors"
7+ "io"
78 "os"
89 "strings"
910 "sync"
@@ -93,7 +94,7 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) {
9394 Kind string `json:"kind"`
9495 Valid bool `json:"valid"`
9596 }
96- for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
97+ for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
9798 var mark struct {
9899 ProcessID string `json:"process_id"`
99100 Scenario string `json:"scenario"`
@@ -126,6 +127,82 @@ func assertSSHReceiveFailurePair(t *testing.T, path string) {
126127 }
127128 }
128129
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+
129206 func TestTransportObservabilitySSHStdioPreservesCarriage(t *testing.T) {
130207 tracePath := t.TempDir() + "/ssh.jsonl"
131208 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) {
10401040
10411041 // sshstdio.Close owns and closes writers that implement io.Closer so a
10421042 // blocked Send can unwind. Keep process-global stdin/stdout outside that
1043- // ownership boundary: input is a non-closing view and output is an owned
1044- // pipe drained by a small forwarding goroutine.
1043+ // ownership boundary: sshstdio recognizes process stdin as a shared
1044+ // cancellable pump, while output is an owned pipe drained by a forwarding
1045+ // goroutine.
10451046 stdin, stdout := os.Stdin, os.Stdout
10461047 stdoutReader, stdoutWriter := io.Pipe()
10471048 forwardDone := make(chan struct{})
M internal/ports/wire.go +27 -11
@@ -26,6 +26,7 @@ var errShortPayload = errors.New("ports: payload too short")
2626 var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2727
2828 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
29+var errInvalidEnum = errors.New("ports: invalid enum value")
2930
3031 const (
3132 outputHeaderLen = 5*8 + 2*2 + 1
@@ -170,6 +171,15 @@ func validClientNoticeAction(action uint8) bool {
170171 }
171172 }
172173
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+
173183 // Detach asks the daemon to detach the current client without killing the
174184 // session.
175185 type Detach struct{}
@@ -214,7 +224,7 @@ type Output struct {
214224 }
215225
216226 // 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.
218228 type AttachTarget struct {
219229 Endpoint string
220230 Session string
@@ -817,6 +827,9 @@ func UnmarshalTheme(b []byte) (Theme, error) {
817827 if err != nil {
818828 return Theme{}, err
819829 }
830+ if flags&^uint8(0x1f) != 0 {
831+ return Theme{}, errInvalidEnum
832+ }
820833 fgR, err := r.getUint8()
821834 if err != nil {
822835 return Theme{}, err
@@ -994,11 +1007,11 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
9941007 if m.SessionName, err = r.getString(); err != nil {
9951008 return Welcome{}, err
9961009 }
997- eph, err := r.getUint8()
998- if err != nil {
1010+ var ephemeral bool
1011+ if ephemeral, err = r.getBool(); err != nil {
9991012 return Welcome{}, err
10001013 }
1001- m.Ephemeral = eph != 0
1014+ m.Ephemeral = ephemeral
10021015 if m.ResumeToken, err = r.getUint64(); err != nil {
10031016 return Welcome{}, err
10041017 }
@@ -1185,6 +1198,9 @@ func UnmarshalDetached(b []byte) (Detached, error) {
11851198 if err != nil {
11861199 return Detached{}, err
11871200 }
1201+ if !validDetachedReason(reason) {
1202+ return Detached{}, errInvalidEnum
1203+ }
11881204 if err := r.done(); err != nil {
11891205 return Detached{}, err
11901206 }
@@ -1312,9 +1328,9 @@ func UnmarshalKill(b []byte) (Kill, error) {
13121328 if err != nil {
13131329 return Kill{}, err
13141330 }
1315- var all uint8
1331+ var all bool
13161332 if len(r.b) > 0 {
1317- all, err = r.getUint8()
1333+ all, err = r.getBool()
13181334 if err != nil {
13191335 return Kill{}, err
13201336 }
@@ -1322,7 +1338,7 @@ func UnmarshalKill(b []byte) (Kill, error) {
13221338 if err := r.done(); err != nil {
13231339 return Kill{}, err
13241340 }
1325- return Kill{Name: name, All: all != 0}, nil
1341+ return Kill{Name: name, All: all}, nil
13261342 }
13271343
13281344 // MarshalSessions encodes m into a Sessions message payload: a uint16 count
@@ -1365,19 +1381,19 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
13651381 if s.Name, err = r.getString(); err != nil {
13661382 return Sessions{}, err
13671383 }
1368- eph, err := r.getUint8()
1384+ eph, err := r.getBool()
13691385 if err != nil {
13701386 return Sessions{}, err
13711387 }
1372- s.Ephemeral = eph != 0
1388+ s.Ephemeral = eph
13731389 if s.Tabs, err = r.getUint16(); err != nil {
13741390 return Sessions{}, err
13751391 }
1376- att, err := r.getUint8()
1392+ att, err := r.getBool()
13771393 if err != nil {
13781394 return Sessions{}, err
13791395 }
1380- s.Attached = att != 0
1396+ s.Attached = att
13811397 state, err := r.getUint8()
13821398 if err != nil {
13831399 return Sessions{}, err
M internal/ports/wire_final_test.go +68 -0
@@ -195,3 +195,71 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
195195 }
196196 }
197197 }
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
22
33 import "github.com/bnema/vev/internal/ports"
44
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-
145 // attachmentConnectionToken is the exact capability for one registered
156 // attachment connection incarnation. It is invalid as soon as any of the
167 // session, attachment membership, connection generation, transport link, or
178 // coordinator lease changes.
189 type attachmentConnectionToken struct {
19- sess attachmentSession
10+ sess *session
2011 ac *attachedClient
2112 generation uint64
2213 transport transportSnapshot
@@ -27,7 +18,7 @@ type attachmentConnectionToken struct {
2718 rebase bool
2819 }
2920
30-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
21+func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
3122 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
3223 return attachmentConnectionToken{}
3324 }
@@ -70,7 +61,7 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
7061 }
7162
7263 // 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.
7465 func (t attachmentConnectionToken) current() bool {
7566 return t.sess != nil && t.ac != nil &&
7667 t.ac.connectionGeneration.Load() == t.generation &&
@@ -100,8 +91,8 @@ func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
10091 return t.attachmentCurrent()
10192 }
10293
103-func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
104- if sess == nil || ac == nil || lease == nil || lease.attachment != ac {
94+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
95+ if sess == nil || ac == nil || lease == nil {
10596 return nil, false
10697 }
10798 token := attachmentToken(sess, ac, ac.transport())
M internal/usecase/daemon/attachment_effect_gate.go +1 -1
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type attachmentCapability struct {
21- sess attachmentSession
21+ sess *session
2222 generation uint64
2323 transport transportSnapshot
2424 lease *attachmentLease
M internal/usecase/daemon/attachment_effect_gate_test.go +18 -18
@@ -165,7 +165,7 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
165165
166166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168- rc := d.attachCoordinator(sess, ac, true)
168+ rc := d.attachCoordinator(sess, nil, ac, true)
169169 foreign := &attachmentLease{attachment: &attachedClient{}}
170170 _, admitted := beginAttachmentLeaseEffect(sess, ac, foreign)
171171 require.False(t, admitted)
@@ -196,7 +196,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
196196 sess.registerAttachmentLocked(later)
197197 sess.mu.Unlock()
198198
199- rc := d.attachCoordinator(sess, first, true)
199+ rc := d.attachCoordinator(sess, nil, first, true)
200200 firstToken := sess.attachmentToken(first, firstTransport)
201201 firstToken.lease = rc.attachmentLease(first)
202202 first.publishAttachmentCapability(firstToken)
@@ -302,7 +302,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
302302 d, sess, snatched, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
303303 snatchedTransport := newTeardownBlockingTransport()
304304 snatched.replaceTransport(snatchedTransport)
305- d.attachCoordinator(sess, snatched, true)
305+ d.attachCoordinator(sess, nil, snatched, true)
306306 snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
307307
308308 activeTransport := newTeardownBlockingTransport()
@@ -322,7 +322,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
322322 d.mu.Lock()
323323 d.sessions[unrelated.id] = unrelated
324324 d.mu.Unlock()
325- d.attachCoordinator(unrelated, unrelatedClient, true)
325+ d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326326
327327 snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328328 activeToken := sess.attachmentToken(active, activeTransport)
@@ -358,7 +358,7 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
358358 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
359359 stale := newTeardownBlockingTransport()
360360 ac.replaceTransport(stale)
361- d.attachCoordinator(sess, ac, true)
361+ d.attachCoordinator(sess, nil, ac, true)
362362 ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
363363
364364 token := sess.attachmentToken(ac, stale)
@@ -387,7 +387,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
387387 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
388388 transport := &closeTrackingTransport{}
389389 active.replaceTransport(transport)
390- d.attachCoordinator(sess, active, true)
390+ d.attachCoordinator(sess, nil, active, true)
391391 active.publishAttachmentCapability(sess.attachmentToken(active, transport))
392392
393393 owner := freezeAttachmentEffectGates(active)
@@ -418,7 +418,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
418418 d, sess, active, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
419419 blocked := newTeardownBlockingTransport()
420420 active.replaceTransport(blocked)
421- d.attachCoordinator(sess, active, true)
421+ d.attachCoordinator(sess, nil, active, true)
422422 active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
423423
424424 token := sess.attachmentToken(active, blocked)
@@ -444,7 +444,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
444444 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
445445 transport := &closeTrackingTransport{}
446446 ac.replaceTransport(transport)
447- rc := d.attachCoordinator(sess, ac, true)
447+ rc := d.attachCoordinator(sess, nil, ac, true)
448448 token := sess.attachmentToken(ac, transport)
449449 token.lease = rc.attachmentLease(ac)
450450 ac.publishAttachmentCapability(token)
@@ -474,7 +474,7 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
474474 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
475475 oldTransport := newBlockedRenderReplacementTransport(d, sess)
476476 old.replaceTransport(oldTransport)
477- rc := d.attachCoordinator(sess, old, true)
477+ rc := d.attachCoordinator(sess, nil, old, true)
478478 lease := rc.attachmentLease(old)
479479 token := sess.attachmentToken(old, oldTransport)
480480 token.lease = lease
@@ -529,7 +529,7 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
529529 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
530530 oldTransport := newDatagramTestTransport()
531531 old.replaceTransport(oldTransport)
532- rc := d.attachCoordinator(sess, old, true)
532+ rc := d.attachCoordinator(sess, nil, old, true)
533533 token := sess.attachmentToken(old, oldTransport)
534534 token.lease = rc.attachmentLease(old)
535535 old.publishAttachmentCapability(token)
@@ -630,7 +630,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
630630 oldTransport := newDatagramTestTransport()
631631 old.replaceTransport(oldTransport)
632632 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)
634634 token := sess.attachmentToken(old, oldTransport)
635635 token.lease = rc.attachmentLease(old)
636636 if tt.setup != nil {
@@ -683,7 +683,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
683683 target.tabs[1].attentionAt = time.Unix(1, 0)
684684 d.sessions[target.id] = target
685685
686- rc := d.attachCoordinator(source, ac, true)
686+ rc := d.attachCoordinator(source, nil, ac, true)
687687 token := source.attachmentToken(ac, ac.transport())
688688 token.lease = rc.attachmentLease(ac)
689689 ac.publishAttachmentCapability(token)
@@ -701,7 +701,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
701701 d.enterPicker(sess, old)
702702
703703 oldTransport := old.transport()
704- rc := d.attachCoordinator(sess, old, true)
704+ rc := d.attachCoordinator(sess, nil, old, true)
705705 token := sess.attachmentToken(old, oldTransport)
706706 token.lease = rc.attachmentLease(old)
707707 old.publishAttachmentCapability(token)
@@ -745,7 +745,7 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
745745 defer release()
746746 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
747747 d.enterPicker(sess, ac)
748- rc := d.attachCoordinator(sess, ac, true)
748+ rc := d.attachCoordinator(sess, nil, ac, true)
749749 token := sess.attachmentToken(ac, ac.transport())
750750 token.lease = rc.attachmentLease(ac)
751751 ac.publishAttachmentCapability(token)
@@ -762,7 +762,7 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
762762 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
763763 oldTransport := newDatagramTestTransport()
764764 old.replaceTransport(oldTransport)
765- rc := d.attachCoordinator(sess, old, true)
765+ rc := d.attachCoordinator(sess, nil, old, true)
766766 token := sess.attachmentToken(old, oldTransport)
767767 token.lease = rc.attachmentLease(old)
768768
@@ -796,7 +796,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
796796 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
797797 aTransport := newDatagramTestTransport()
798798 a.replaceTransport(aTransport)
799- d.attachCoordinator(first, a, true)
799+ d.attachCoordinator(first, nil, a, true)
800800 first.attachmentToken(a, aTransport)
801801
802802 bTransport := newDatagramTestTransport()
@@ -807,7 +807,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
807807 d.mu.Lock()
808808 d.sessions[second.id] = second
809809 d.mu.Unlock()
810- d.attachCoordinator(second, b, true)
810+ d.attachCoordinator(second, nil, b, true)
811811 second.attachmentToken(b, bTransport)
812812
813813 start := make(chan struct{})
@@ -854,7 +854,7 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
854854 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
855855 transport := newDatagramTestTransport()
856856 ac.replaceTransport(transport)
857- rc := d.attachCoordinator(sess, ac, true)
857+ rc := d.attachCoordinator(sess, nil, ac, true)
858858 token := sess.attachmentToken(ac, transport)
859859 token.lease = rc.attachmentLease(ac)
860860 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/attachment_registry.go +91 -69
@@ -23,13 +23,11 @@ type attachmentView struct {
2323 revision uint64
2424 }
2525
26-// viewInvalidation records the order in which attachment views observed one
27-// shared session mutation. The order is stable client-ID order, not map
28-// iteration or lock-acquisition order, which keeps concurrent attaches
29-// deterministic.
30-type viewInvalidation struct {
26+// attachmentViewSnapshot is the immutable value returned to callers that need
27+// to inspect attachment-local state without retaining its mutex.
28+type attachmentViewSnapshot struct {
3129 attachment *attachedClient
32- revision uint64
30+ view attachmentView
3331 }
3432
3533 func (ac *attachedClient) viewSnapshot() attachmentView {
@@ -41,11 +39,7 @@ func (ac *attachedClient) viewSnapshot() attachmentView {
4139 return ac.view
4240 }
4341
44-func (ac *attachedClient) publishView(view attachmentView) {
45- if ac == nil {
46- return
47- }
48- ac.viewMu.Lock()
42+func (ac *attachedClient) publishViewLocked(view attachmentView) {
4943 // Rebase while viewMu is held, before the new revision becomes visible.
5044 // Output fences take the same lock, so no side effect can pair the new
5145 // ViewRevision with the previous output epoch. Test/headless fixtures may
@@ -60,7 +54,28 @@ func (ac *attachedClient) publishView(view attachmentView) {
6054 }
6155 }
6256 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
6479 }
6580
6681 // registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
@@ -96,7 +111,7 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
96111 return true
97112 }
98113
99-func registerAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
114+func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
100115 if entry == nil || entry.core() == nil {
101116 return false
102117 }
@@ -138,7 +153,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
138153 return true
139154 }
140155
141-func unregisterAttachmentSessionLocked(entry attachmentSession, ac *attachedClient) bool {
156+func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
142157 if entry == nil || entry.core() == nil {
143158 return false
144159 }
@@ -156,26 +171,26 @@ func (s *session) unregisterAttachment(ac *attachedClient) bool {
156171 return s.unregisterAttachmentLocked(ac)
157172 }
158173
159-// snapshotAttachmentsLocked returns a stable registration-ordered snapshot.
160-// Callers hold s.mu; returned attachments may be used after unlocking.
161-func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
162- if c == nil || len(c.attachments) == 0 {
174+func (s *session) snapshotAttachmentsLocked() []*attachedClient {
175+ if s == nil || len(s.attachments) == 0 {
163176 return nil
164177 }
165- out := make([]*attachedClient, 0, len(c.attachments))
166- for ac := range c.attachments {
178+ out := make([]*attachedClient, 0, len(s.attachments))
179+ for ac := range s.attachments {
167180 out = append(out, ac)
168181 }
169182 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.
170185 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
171186 return id
172187 }
173- return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b])
188+ return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
174189 })
175190 return out
176191 }
177192
178-func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) bool {
193+func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
179194 if entry == nil || ac == nil || entry.core() == nil {
180195 return false
181196 }
@@ -183,7 +198,7 @@ func attachmentRegisteredLocked(entry attachmentSession, ac *attachedClient) boo
183198 return ok
184199 }
185200
186-func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
201+func attachmentRegistered(entry *session, ac *attachedClient) bool {
187202 if entry == nil || ac == nil || entry.core() == nil {
188203 return false
189204 }
@@ -213,16 +228,6 @@ func (s *session) attachmentViewsTabLocked(tb *tab) bool {
213228 return false
214229 }
215230
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-
226231 func (s *session) snapshotAttachments() []*attachedClient {
227232 if s == nil {
228233 return nil
@@ -232,6 +237,18 @@ func (s *session) snapshotAttachments() []*attachedClient {
232237 return s.snapshotAttachmentsLocked()
233238 }
234239
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+
235252 // repairAttachmentViewLocked validates stable targets and chooses the nearest
236253 // surviving target when a tab or pane was removed. Caller holds s.mu.
237254 func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
@@ -279,10 +296,12 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
279296 } else {
280297 view.paneID = ""
281298 }
299+ targetTab.mu.Unlock()
282300 if view.windowRows <= 0 {
301+ targetTab.mu.Lock()
283302 view.windowRows = targetTab.size.Rows
303+ targetTab.mu.Unlock()
284304 }
285- targetTab.mu.Unlock()
286305 if view.windowTop < 0 {
287306 view.windowTop = 0
288307 }
@@ -301,19 +320,20 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
301320 if s == nil || ac == nil {
302321 return false
303322 }
323+ before := ac.viewSnapshot()
304324 s.mu.Lock()
305- defer s.mu.Unlock()
306325 if _, ok := s.attachments[ac]; !ok {
326+ s.mu.Unlock()
307327 return false
308328 }
309- before := ac.viewSnapshot()
310329 after := s.repairAttachmentViewLocked(ac, before)
311330 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()
312332 return false
313333 }
314334 after.revision++
315- ac.publishView(after)
316- return true
335+ s.mu.Unlock()
336+ return ac.publishViewIfCurrent(before, after)
317337 }
318338
319339 // prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
@@ -347,17 +367,13 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
347367
348368 // invalidateViewsLocked orders all per-attachment invalidations behind the
349369 // shared session mutation. Caller holds s.mu and dispatchMu.
350-func (s *session) invalidateViewsLocked() []viewInvalidation {
351- attachments := s.snapshotAttachmentsLocked()
352- invalidations := make([]viewInvalidation, 0, len(attachments))
353- for _, ac := range attachments {
370+func (s *session) invalidateViewsLocked() {
371+ for _, ac := range s.snapshotAttachmentsLocked() {
354372 view := ac.viewSnapshot()
355373 repaired := s.repairAttachmentViewLocked(ac, view)
356374 repaired.revision++
357375 ac.publishView(repaired)
358- invalidations = append(invalidations, viewInvalidation{attachment: ac, revision: repaired.revision})
359376 }
360- return invalidations
361377 }
362378
363379 // runMutation is the one admission boundary for shared session mutations.
@@ -378,23 +394,31 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
378394 if s == nil || ac == nil {
379395 return nil
380396 }
381- s.mu.Lock()
382- defer s.mu.Unlock()
383- if _, ok := s.attachments[ac]; !ok {
384- return nil
385- }
386- view := ac.viewSnapshot()
387- repaired := s.repairAttachmentViewLocked(ac, view)
388- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
389- repaired.revision++
390- ac.publishView(repaired)
391- }
392- for _, tb := range s.tabs {
393- if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
394- return tb
397+ for {
398+ view := ac.viewSnapshot()
399+ s.mu.Lock()
400+ if _, ok := s.attachments[ac]; !ok {
401+ s.mu.Unlock()
402+ return nil
403+ }
404+ repaired := s.repairAttachmentViewLocked(ac, view)
405+ var target *tab
406+ for _, tb := range s.tabs {
407+ if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
408+ target = tb
409+ break
410+ }
395411 }
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
396421 }
397- return nil
398422 }
399423
400424 // paneForAttachment resolves both stable IDs and repairs a removed pane before
@@ -578,26 +602,24 @@ func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta
578602
579603 // repairAttachmentViews repairs all live attachment targets after a shared
580604 // topology mutation.
581-func (s *session) repairAttachmentViews() []viewInvalidation {
605+func (s *session) repairAttachmentViews() {
582606 if s == nil {
583- return nil
607+ return
584608 }
585- var invalidations []viewInvalidation
586609 _ = s.runMutation(func() error {
587610 s.mu.Lock()
588- invalidations = s.invalidateViewsLocked()
611+ s.invalidateViewsLocked()
589612 s.mu.Unlock()
590613 return nil
591614 })
592- return invalidations
593615 }
594616
595617 // repairAttachmentViewsLocked repairs all live attachment targets after a tab
596-// or pane mutation. Caller holds dispatchMu and s.mu; it is intended to run at
597-// the same mutation linearization point as the shared membership change.
598-func (s *session) repairAttachmentViewsLocked() []viewInvalidation {
618+// or pane mutation. It is intended to run at the same mutation linearization
619+// point as the shared membership change.
620+func (s *session) repairAttachmentViewsLocked() {
599621 if s == nil {
600- return nil
622+ return
601623 }
602- return s.invalidateViewsLocked()
624+ s.invalidateViewsLocked()
603625 }
M internal/usecase/daemon/attachment_registry_test.go +10 -3
@@ -186,10 +186,17 @@ func TestAttachmentRegistryConcurrentRegistrationSnapshotIsStable(t *testing.T)
186186 t.Fatalf("snapshot[%d] id = %d, want %d", i, got[i].clientID[0], want)
187187 }
188188 }
189- invalidations := sess.repairAttachmentViews()
189+ ordered := sess.repairAttachmentViewsForTest()
190190 for i, want := range []byte{1, 2, 3} {
191- if invalidations[i].attachment.clientID[0] != want {
192- t.Fatalf("invalidation[%d] id = %d, want %d", i, invalidations[i].attachment.clientID[0], want)
191+ if ordered[i].clientID[0] != want {
192+ t.Fatalf("ordered attachment[%d] id = %d, want %d", i, ordered[i].clientID[0], want)
193193 }
194194 }
195195 }
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 {
3131 func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
3232 func (*blockedAttachmentTransport) Close() error { return nil }
3333
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+
34100 func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
35101 pty, releasePTY := newBlockingPTY(t)
36102 defer releasePTY()
@@ -76,6 +142,83 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
76142 require.Greater(t, second.output.currentEpoch(), secondEpoch)
77143 }
78144
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+
79222 func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80223 pty, releasePTY := newBlockingPTY(t)
81224 defer releasePTY()
M internal/usecase/daemon/attachment_transition.go +4 -4
@@ -20,8 +20,8 @@ type attachmentLifecycleFence struct {
2020 }
2121
2222 type attachmentTransitionRequest struct {
23- source attachmentSession
24- target attachmentSession
23+ source *session
24+ target *session
2525 next *attachedClient
2626 expectedTransport transportSnapshot
2727 sourceToken *attachmentConnectionToken
@@ -44,7 +44,7 @@ type attachmentTransitionRequest struct {
4444 attachmentEffectsFrozen bool
4545 }
4646
47-func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
4848 return token.sess == source && token.ac == req.next &&
4949 token.generation == req.next.connectionGeneration.Load() &&
5050 token.transport.transport == req.expectedTransport.transport &&
@@ -54,7 +54,7 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
5454 // transitionSourceTokenCurrentLocked requires source.core().mu and, when a
5555 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
5656 // 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 {
5858 if token.sess != source || token.ac != req.next ||
5959 token.generation != req.next.connectionGeneration.Load() ||
6060 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
5050 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5151 }
5252
53-func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool {
53+func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
5454 return transitionSourceTabCurrentForRequestLocked(source, expected, false)
5555 }
5656
57-func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool {
57+func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
5858 if expected == nil {
5959 return true
6060 }
61- local, ok := localSession(source)
62- if !ok || !containsTab(local.tabs, expected) {
61+ if source == nil || !containsTab(source.tabs, expected) {
6362 return false
6463 }
6564 expected.mu.Lock()
@@ -81,7 +80,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
8180
8281 type attachmentPublication struct {
8382 req attachmentTransitionRequest
84- source attachmentSession
83+ source *session
8584 targetCoordinator *renderCoordinator
8685 sourceCoordinator *renderCoordinator
8786 releaseCoordinators func()
@@ -92,22 +91,21 @@ func (p *attachmentPublication) unlockCoordinators() {
9291 p.releaseCoordinators()
9392 }
9493
95-func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachmentLifecycleFence) bool {
94+func attachmentLifecycleCurrentLocked(entry *session, fence *attachmentLifecycleFence) bool {
9695 if fence == nil {
9796 return true
9897 }
99- target, ok := localSession(entry)
100- if !ok {
98+ if entry == nil {
10199 return false
102100 }
103- if fence.checkCreatedAt && (target.name != fence.name || target.createdAt != fence.createdAt) {
101+ if fence.checkCreatedAt && (entry.name != fence.name || entry.createdAt != fence.createdAt) {
104102 return false
105103 }
106- if fence.checkIncarnation && target.incarnation != fence.incarnation {
104+ if fence.checkIncarnation && entry.incarnation != fence.incarnation {
107105 return false
108106 }
109- return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(target.tabs) &&
110- domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
107+ return !fence.checkTab || fence.tabIndex >= 0 && fence.tabIndex < len(entry.tabs) &&
108+ domain.TabStableID(entry.tabs[fence.tabIndex].stableID) == fence.tabID
111109 }
112110
113111 // validateAttachmentTransitionPrelocked performs every fallible check before
@@ -174,18 +172,12 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
174172 // shared environment state as part of the same publication.
175173 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
176174 req := publication.req
177- if req.activateTargetTab {
178- if target, ok := localSession(req.target); ok {
179- target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
180- }
175+ if req.activateTargetTab && req.target != nil {
176+ req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
181177 }
182- if req.copySourceEnvironment {
183- source, sourceOK := localSession(publication.source)
184- target, targetOK := localSession(req.target)
185- if sourceOK && targetOK {
186- target.terminal = source.terminal
187- target.env = copyEnvironment(source.env)
188- }
178+ if req.copySourceEnvironment && publication.source != nil && req.target != nil {
179+ req.target.terminal = publication.source.terminal
180+ req.target.env = copyEnvironment(publication.source.env)
189181 }
190182 return req.preserveAttachment
191183 }
@@ -259,9 +251,12 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
259251 }
260252
261253 // lockAttachmentSessions gives every two-session transition one stable order.
262-func lockAttachmentSessions(a, b attachmentSession) func() {
263- aCore := attachmentSessionCore(a)
264- bCore := attachmentSessionCore(b)
254+func lockAttachmentSessions(a, b *session) func() {
255+ if a == nil || b == nil {
256+ return func() {}
257+ }
258+ aCore := a.core()
259+ bCore := b.core()
265260 if aCore == nil || bCore == nil {
266261 return func() {}
267262 }
@@ -283,7 +278,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
283278
284279 // lockAttachmentCoordinators follows the same immutable session-ID order as
285280 // 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() {
287282 if aCoordinator == nil && bCoordinator == nil {
288283 return func() {}
289284 }
M internal/usecase/daemon/attention.go +4 -4
@@ -90,8 +90,8 @@ type attentionTarget struct {
9090 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
9191 d.mu.Lock()
9292 sessions := make([]*session, 0, len(d.sessions))
93- for _, entry := range d.sessions {
94- if sess, ok := localSession(entry); ok && sess != current {
93+ for _, sess := range d.sessions {
94+ if sess != nil && sess != current {
9595 sessions = append(sessions, sess)
9696 }
9797 }
@@ -137,7 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137137
138138 func (d *Daemon) repaintAllAttachedClients() {
139139 d.mu.Lock()
140- sessions := localSessionsSnapshot(d.sessions)
140+ sessions := sessionsSnapshot(d.sessions)
141141 d.mu.Unlock()
142142
143143 for _, sess := range sessions {
@@ -245,7 +245,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
245245
246246 func (d *Daemon) anyAttention() bool {
247247 d.mu.Lock()
248- sessions := localSessionsSnapshot(d.sessions)
248+ sessions := sessionsSnapshot(d.sessions)
249249 d.mu.Unlock()
250250
251251 for _, sess := range sessions {
M internal/usecase/daemon/bar_refresh.go +2 -3
@@ -196,7 +196,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
196196 func (d *Daemon) sessionsSnapshot() []*session {
197197 d.mu.Lock()
198198 defer d.mu.Unlock()
199- return localSessionsSnapshot(d.sessions)
199+ return sessionsSnapshot(d.sessions)
200200 }
201201
202202 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
@@ -390,8 +390,7 @@ func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command
390390 return
391391 }
392392 attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393- var scriptErr *barScriptError
394- if errors.As(err, &scriptErr) {
393+ if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
395394 attrs = append(attrs, "exit_code", scriptErr.exitCode)
396395 if scriptErr.stderr != "" {
397396 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) {
219219 d := newBarRefreshTestDaemon(r, 60*time.Second)
220220 sess := newBarRefreshTestSession()
221221 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}
223223
224224 // Verifies refreshBarScriptsAllSessions iterates every live session and
225225 // 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 {
4040 overlayOnce sync.Once
4141 clientID [16]byte
4242 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
43- // the linearization gate for every attachment-bound observable operation. An attachment
43+ // the linearization gate for every attachment-bound observable operation. A role
4444 // transition freezes and drains this gate before changing either generation
4545 // or the session/coordinator registries.
4646 connectionGeneration atomic.Uint64
@@ -73,7 +73,7 @@ type attachedClient struct {
7373 // tabs and panes without changing shared session ownership.
7474 viewMu sync.Mutex
7575 view attachmentView
76- sess Guarded[attachmentSession]
76+ sess Guarded[*session]
7777 mouseScan mouse.Scanner
7878 themeMu sync.Mutex
7979 clientTheme themeui.Theme
@@ -82,7 +82,7 @@ type attachedClient struct {
8282 renderStages renderStageHooks // optional render and handoff observability hooks
8383 // previousSession is guarded independently. It is retained through temporary
8484 // setSession(nil) hand-offs and cleared only on terminal teardown.
85- previousSession Guarded[attachmentSession]
85+ previousSession Guarded[*session]
8686 linkMu sync.Mutex
8787 sendMu sync.Mutex
8888 // routeCreatedSession marks a session created by this attachment's route.
@@ -141,15 +141,14 @@ func (ac *attachedClient) initOverlays() {
141141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
142142 }
143143
144-func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
144+func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
145145
146146 // currentSession narrows the attachment to its owning local session.
147147 func (ac *attachedClient) currentSession() *session {
148- sess, _ := localSession(ac.currentAttachmentSession())
149- return sess
148+ return ac.currentAttachmentSession()
150149 }
151150
152-func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) }
151+func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
153152
154153 func (ac *attachedClient) clearPreviousSession() {
155154 if ac != nil {
@@ -558,13 +557,13 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
558557 // attachCoordinator installs the coordinator lease for an attachment. Session
559558 // membership remains independent, so installing one attachment never displaces
560559 // another.
561-func (d *Daemon) attachCoordinator(sess *session, current *attachedClient, ready bool) *renderCoordinator {
562- rc, cleanup := d.attachCoordinatorDeferred(sess, current, ready)
560+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
561+ rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
563562 cleanup.finish()
564563 return rc
565564 }
566565
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) {
568567 rc := d.ensureRenderCoordinator(sess)
569568 if current != nil {
570569 rc.attachWithReadiness(current, ready)
@@ -588,8 +587,8 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
588587 }
589588
590589 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
591-// exact attachment implementation. The caller holds entry.core().mu.
592-func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
590+// exact session. The caller holds entry.core().mu.
591+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
593592 if rc := attachmentRenderCoordinator(entry); rc != nil {
594593 return rc
595594 }
@@ -604,7 +603,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
604603 // The session snapshot is deterministic. Each paint revalidates the
605604 // attachment's membership and transport fence after that snapshot, so
606605 // a detached peer cannot stop remaining attachments receiving output.
607- attachments := snapshotAttachmentSession(entry)
606+ attachments := entry.snapshotAttachments()
608607 for _, attachment := range attachments {
609608 if !rc.wakeCurrent(w) {
610609 return
@@ -614,27 +613,33 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
614613 continue
615614 }
616615 // Pane damage is session-shared; every fan-out attachment needs a
617- // complete frame because any peer may capture it first. A handshake
618- // target is the one synchronous exception: its first paint must finish
619- // before the handshake releases its lifecycle gate.
616+ // fresh capture because any peer may acknowledge the damage first.
617+ // Each attachment owns its send lock, so a blocked peer cannot stall
618+ // this fan-out or the PTY reader. A handshake-targeted wake is the
619+ // one synchronous exception: its first paint must finish before the
620+ // handshake deadline is released.
620621 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+ }
621627 if w.lease != nil && w.lease.attachment == attachment {
622- d.paint(entry, attachment, fanoutReset, lease)
628+ paint()
623629 } else if len(attachments) > 1 {
624- go d.paint(entry, attachment, fanoutReset, lease)
625-
630+ go paint()
626631 } else {
627- d.paint(entry, attachment, fanoutReset, lease)
632+ paint()
628633 }
629634 }
630635 },
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+ },
631642 })
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- }
638643 installAttachmentRenderCoordinator(entry, rc)
639644 return rc
640645 }
@@ -684,11 +689,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
684689 token.ac.captureFrames = nil
685690 token.ac.sendMu.Unlock()
686691 }
687- sess, ok := localSession(token.sess)
688- if !ok {
692+ if token.sess == nil {
689693 return false
690694 }
691- d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease)
695+ d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
692696 return true
693697 }
694698
@@ -752,8 +756,8 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
752756 }
753757 clientTheme := themeFromMessage(msg)
754758 token.ac.setClientTheme(clientTheme)
755- sess, ok := localSession(token.sess)
756- if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
759+ sess := token.sess
760+ if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
757761 return false
758762 }
759763 if !token.attachmentEffectCurrent() {
@@ -799,8 +803,8 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
799803 ac.pipelineScratch = composeCacheInput{}
800804 ac.sendMu.Unlock()
801805
802- sess, ok := localSession(token.sess)
803- if !ok {
806+ sess := token.sess
807+ if sess == nil {
804808 return false
805809 }
806810 rc := sess.renderCoordinator()
@@ -847,12 +851,11 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
847851 d.notices.routingMu.Unlock()
848852 return
849853 }
850- sess, ok := localSession(token.sess)
851- if !ok {
854+ if token.sess == nil {
852855 d.notices.routingMu.Unlock()
853856 return
854857 }
855- repaint := d.mutateClientNotice(sess, token.ac, notice)
858+ repaint := d.mutateClientNotice(token.sess, token.ac, notice)
856859 d.notices.routingMu.Unlock()
857860 if repaint && token.attachmentEffectCurrent() {
858861 d.repaintForNotice(token.ac)
M internal/usecase/daemon/client_frame_routing.go +5 -9
@@ -13,7 +13,7 @@ const connectionSnapshotAttempts = 8
1313 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
1414 // handoff lands between those reads. The attachment gate still performs the final
1515 // 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) {
1717 for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
@@ -54,11 +54,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5454 if !ok {
5555 return
5656 }
57- local, ok := localSession(sess)
58- if !ok {
59- return
60- }
61- d.clientGone(local, ac, tr, false)
57+ d.clientGone(sess, ac, tr, false)
6258 current := ac.currentAttachmentSession()
6359 if current == sess || !ac.currentTransportIs(tr) || current == nil {
6460 return
@@ -180,8 +176,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
180176 result.Text = "attached commands cannot override their active session target"
181177 return result
182178 }
183- sess, ok := localSession(token.sess)
184- if !ok || !token.attachmentEffectCurrent() {
179+ sess := token.sess
180+ if sess == nil || !token.attachmentEffectCurrent() {
185181 result.Code = ports.ErrNoSuchTarget
186182 result.Text = "attached session is no longer active"
187183 return result
@@ -221,7 +217,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
221217 return result
222218 }
223219
224-// resetOutput rebases only the exact proxied attachment and transport admitted
220+// resetOutput rebases only the exact attachment and transport admitted
225221 // by the frame router. sendMu serializes the rebase with every output send;
226222 // transport revalidation under that lock rejects a link replaced while waiting.
227223 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
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- sess, ok := localSession(token.sess)
41- if !ok {
40+ sess := token.sess
41+ if sess == nil {
4242 return false
4343 }
4444 var parkingToken uint64
@@ -126,8 +126,8 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
126126 if failed == nil || failed != token.transport.transport {
127127 return
128128 }
129- sess, ok := localSession(token.sess)
130- if !ok {
129+ sess := token.sess
130+ if sess == nil {
131131 return
132132 }
133133 parkingToken := d.markParkingInFlight(sess, token.ac)
M internal/usecase/daemon/clipboard.go +4 -4
@@ -89,8 +89,8 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke
8989 if err != nil {
9090 return "", err
9191 }
92- sess, ok := localSession(token.sess)
93- if !ok {
92+ sess := token.sess
93+ if sess == nil {
9494 _ = os.Remove(path)
9595 return "", errAttachmentTransition
9696 }
@@ -135,8 +135,8 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135135 }
136136
137137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess, ok := localSession(token.sess)
139- if !ok || !token.attachmentEffectCurrent() {
138+ sess := token.sess
139+ if sess == nil || !token.attachmentEffectCurrent() {
140140 return
141141 }
142142 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 {
203203 }
204204
205205 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)
207207 token := sess.attachmentToken(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209209 ac.publishAttachmentCapability(token)
M internal/usecase/daemon/config.go +1 -1
@@ -336,7 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
336336
337337 func (d *Daemon) reapplyThemeAllSessions() {
338338 d.mu.Lock()
339- sessions := localSessionsSnapshot(d.sessions)
339+ sessions := sessionsSnapshot(d.sessions)
340340 d.mu.Unlock()
341341
342342 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
186186 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
187187 }
188188 var match *session
189- for _, entry := range d.sessions {
190- sess, ok := localSession(entry)
191- if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
189+ for _, sess := range d.sessions {
190+ if sess == nil || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
192191 continue
193192 }
194193 if match != nil {
@@ -201,7 +200,7 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
201200 }
202201 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
203202 }
204- locals := localSessionsSnapshot(d.sessions)
203+ locals := sessionsSnapshot(d.sessions)
205204 if len(locals) == 0 {
206205 return nil, ports.ErrNoSuchTarget, "no live sessions"
207206 }
@@ -658,7 +657,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
658657 Active bool `json:"active"`
659658 }
660659 e.d.mu.Lock()
661- sessions := localSessionsSnapshot(e.d.sessions)
660+ sessions := sessionsSnapshot(e.d.sessions)
662661 e.d.mu.Unlock()
663662 snaps := make([]sessionView, 0, len(sessions))
664663 for _, sess := range sessions {
@@ -695,7 +694,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
695694 return "", command.ErrInvalidArguments
696695 }
697696 e.d.mu.Lock()
698- sessions := localSessionsSnapshot(e.d.sessions)
697+ sessions := sessionsSnapshot(e.d.sessions)
699698 e.d.mu.Unlock()
700699 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
701700 // 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}
7070
7171 type Daemon struct {
7272 mu sync.Mutex
73- sessions map[domain.SessionID]attachmentSession
73+ sessions map[domain.SessionID]*session
7474 stopped map[string]stoppedSession
7575 // creating reserves names while durable creation I/O runs without mu.
7676 creating map[string]struct{}
@@ -144,7 +144,7 @@ type Daemon struct {
144144 afterResizeCommitSendLocked func()
145145 // afterConnectionSessionSnapshot is a deterministic test seam after the
146146 // connection loop reads its current session and before it captures that role.
147- afterConnectionSessionSnapshot func(attachmentSession)
147+ afterConnectionSessionSnapshot func(*session)
148148 // afterAttachmentFrameDispatch is a deterministic test seam after the
149149 // connection loop snapshots a token and before a decoded frame takes effect.
150150 afterAttachmentFrameDispatch func(attachmentConnectionToken)
@@ -586,7 +586,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
586586 }
587587 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
588588 d := &Daemon{
589- sessions: make(map[domain.SessionID]attachmentSession),
589+ sessions: make(map[domain.SessionID]*session),
590590 stopped: make(map[string]stoppedSession),
591591 creating: make(map[string]struct{}),
592592 parked: make(map[uint64]*parkedAttachment),
@@ -914,7 +914,7 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
914914
915915 // registerSessionLocked publishes an exact attachment-session identity. Caller
916916 // holds d.mu.
917-func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
917+func (d *Daemon) registerSessionLocked(entry *session) bool {
918918 if entry == nil {
919919 return false
920920 }
@@ -928,7 +928,7 @@ func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
928928
929929 // unregisterSessionLocked removes only the exact registered identity. Caller
930930 // holds d.mu.
931-func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
931+func (d *Daemon) unregisterSessionLocked(entry *session) bool {
932932 if entry == nil {
933933 return false
934934 }
@@ -941,7 +941,7 @@ func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
941941 }
942942
943943 func (d *Daemon) sessionsSnapshotLocked() []*session {
944- return localSessionsSnapshot(d.sessions)
944+ return sessionsSnapshot(d.sessions)
945945 }
946946
947947 // handleConn reads the first frame off a fresh connection and routes it. A
@@ -1001,9 +1001,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
10011001 d.mu.Lock()
10021002 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
10031003 liveNames := make(map[string]struct{}, len(d.sessions))
1004- for _, entry := range d.sessions {
1005- s, ok := localSession(entry)
1006- if !ok {
1004+ for _, s := range d.sessions {
1005+ if s == nil {
10071006 continue
10081007 }
10091008 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
297297 func firstSession(d *Daemon) *session {
298298 d.mu.Lock()
299299 defer d.mu.Unlock()
300- for _, entry := range d.sessions {
301- if s, ok := localSession(entry); ok {
300+ for _, s := range d.sessions {
301+ if s != nil {
302302 return s
303303 }
304304 }
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 {
383383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384384 if h.connectionToken.ac != nil {
385385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- sess, _ := localSession(h.connectionToken.sess)
387- return sess, effect, false
386+ return h.connectionToken.sess, effect, false
388387 }
389388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
390389 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -398,12 +397,11 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
398397 token.effect = effect
399398 h.d.afterAttachmentEffectAdmitted(token)
400399 }
401- sess, ok := localSession(h.connectionToken.sess)
402- if !ok {
400+ if h.connectionToken.sess == nil {
403401 effect.End()
404402 return nil, nil, false
405403 }
406- return sess, effect, true
404+ return h.connectionToken.sess, effect, true
407405 }
408406 sess := h.ac.currentSession()
409407 if sess == nil {
M internal/usecase/daemon/move.go +2 -2
@@ -180,8 +180,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
180180 if d == nil {
181181 return nil
182182 }
183- sess, ok := localSession(d.sessions[locator.ID])
184- if !ok || sess.incarnation != locator.Incarnation {
183+ sess := d.sessions[locator.ID]
184+ if sess == nil || sess.incarnation != locator.Incarnation {
185185 return nil
186186 }
187187 return sess
M internal/usecase/daemon/move_cleanup_test.go +2 -2
@@ -35,7 +35,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
3535 defer releaseDestination()
3636
3737 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))
3939 sourceTab := source.tabs[0]
4040 sourceTab.stableID = "source-tab"
4141 moved := sourceTab.focusedPane()
@@ -117,7 +117,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
117117 sourceTab.stableID = "source-tab"
118118 remainingTab.stableID = "remaining-tab"
119119 moved := sourceTab.focusedPane()
120- require.NotNil(t, d.attachCoordinator(source, displaced, true))
120+ require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
121121
122122 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
123123 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
1919 sourceTab := source.tabs[0]
2020 sourceTab.stableID = "source-tab"
2121 moved := sourceTab.focusedPane()
22- require.NotNil(t, d.attachCoordinator(source, client, true))
22+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
2323
2424 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
2525 destinationTab := destination.tabs[0]
@@ -184,7 +184,7 @@ func TestFinalCloseDoesNotDeadlockWithMoveDispatchAdmission(t *testing.T) {
184184
185185 func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
186186 d, sess, current, _ := newManualSessionWithPTYs(t, newQuietPTY())
187- d.attachCoordinator(sess, current, true)
187+ d.attachCoordinator(sess, nil, current, true)
188188 otherTransport := &closeTrackingTransport{}
189189 other := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: current.size}
190190 other.initOverlays()
@@ -192,7 +192,7 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
192192 sess.mu.Lock()
193193 require.True(t, sess.registerAttachmentLocked(other))
194194 sess.mu.Unlock()
195- d.attachCoordinator(sess, other, true)
195+ d.attachCoordinator(sess, nil, other, true)
196196
197197 currentToken := sess.attachmentToken(current, current.transport())
198198 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 {
1010 if d == nil || source.Session.ID == "" || source.TabID == "" {
1111 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1212 }
13- sess, ok := localSession(d.sessionByID(source.Session.ID))
14- if !ok || sess.incarnation != source.Session.Incarnation {
13+ sess := d.sessionByID(source.Session.ID)
14+ if sess == nil || sess.incarnation != source.Session.Incarnation {
1515 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1616 }
1717 sess.mu.Lock()
@@ -120,8 +120,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
120120
121121 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122122 d.mu.Lock()
123- sess, ok := localSession(d.sessions[target.Session])
124- if !ok {
123+ sess := d.sessions[target.Session]
124+ if sess == nil {
125125 d.mu.Unlock()
126126 return nil, nil
127127 }
M internal/usecase/daemon/move_tab_test.go +2 -2
@@ -152,7 +152,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
152152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153153 moved := source.tabs[0]
154154 moved.stableID = "moved-tab"
155- require.NotNil(t, d.attachCoordinator(source, client, true))
155+ require.NotNil(t, d.attachCoordinator(source, nil, client, true))
156156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158158 moved.mu.Lock()
@@ -453,7 +453,7 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
453453
454454 func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455455 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))
457457 source.incarnation = domain.IncarnationID{4}
458458 moved := source.tabs[0]
459459 moved.stableID = "moved-tab"
M internal/usecase/daemon/navigation_attachment_handoff_test.go +2 -2
@@ -97,7 +97,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
9797 d.sessions[target.id] = target
9898 d.mu.Unlock()
9999
100- rc := d.attachCoordinator(source, old, true)
100+ rc := d.attachCoordinator(source, nil, old, true)
101101 token := source.attachmentToken(old, old.transport())
102102 token.lease = rc.attachmentLease(old)
103103 old.publishAttachmentCapability(token)
@@ -152,7 +152,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
152152 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionStopped}
153153 d.mu.Unlock()
154154
155- rc := d.attachCoordinator(source, old, true)
155+ rc := d.attachCoordinator(source, nil, old, true)
156156 token := source.attachmentToken(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158158 old.publishAttachmentCapability(token)
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +1 -1
@@ -47,7 +47,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
4747 d.sessions[target.id] = target
4848 d.mu.Unlock()
4949
50- rc := d.attachCoordinator(source, ac, true)
50+ rc := d.attachCoordinator(source, nil, ac, true)
5151 token := source.attachmentToken(ac, ac.transport())
5252 token.lease = rc.attachmentLease(ac)
5353 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,
8585 current.mu.Unlock()
8686
8787 d.mu.Lock()
88- live := localSessionsSnapshot(d.sessions)
88+ live := sessionsSnapshot(d.sessions)
8989 d.mu.Unlock()
9090
9191 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
M internal/usecase/daemon/navigation_overflow_test.go +4 -6
@@ -152,9 +152,8 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
152152 d.mu.Lock()
153153 _, stopped := d.stopped["bravo"]
154154 var bravoLive bool
155- for _, entry := range d.sessions {
156- candidate, ok := localSession(entry)
157- if !ok {
155+ for _, candidate := range d.sessions {
156+ if candidate == nil {
158157 continue
159158 }
160159 candidate.mu.Lock()
@@ -322,9 +321,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
322321 current := ac.currentSession()
323322 owners := make([]*session, 0, 1)
324323 d.mu.Lock()
325- for _, entry := range d.sessions {
326- candidate, ok := localSession(entry)
327- if !ok {
324+ for _, candidate := range d.sessions {
325+ if candidate == nil {
328326 continue
329327 }
330328 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
1717
1818 // enterNotices opens the notification history overlay from the daemon's full
1919 // notice center history, newest first.
20-func (d *Daemon) enterNotices(sess attachmentSession, ac *attachedClient) {
20+func (d *Daemon) enterNotices(sess *session, ac *attachedClient) {
2121 history := d.notices.history()
2222 now := d.clock.Now()
2323 ac.overlays.noticeMu.Lock()
@@ -221,13 +221,13 @@ func (d *Daemon) handleNoticesInput(ac *attachedClient, data []byte) {
221221 // OSC52, mirroring copy mode's own yank path (copymode.go handleCopyInput):
222222 // send each chunk, then leave a one-shot status-bar confirmation that the
223223 // 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) {
225225 chunks := scopy.OSC52(noticeYankPayload(n))
226226 for _, chunk := range chunks {
227227 failed, err := d.boundedSendOutputErrTransport(ac, chunk)
228228 if err != nil {
229- if sess, ok := localSession(entry); ok {
230- d.detachOnSendError(sess, ac, failed)
229+ if entry != nil {
230+ d.detachOnSendError(entry, ac, failed)
231231 } else {
232232 _ = ac.closeCapturedTransport(failed)
233233 }
M internal/usecase/daemon/notify.go +7 -5
@@ -236,9 +236,8 @@ func (d *Daemon) reportError(sess *session, err error) {
236236 d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
237237 }
238238
239-func (d *Daemon) reportAttachmentError(entry attachmentSession, err error) {
240- local, _ := localSession(entry)
241- d.reportError(local, err)
239+func (d *Daemon) reportAttachmentError(entry *session, err error) {
240+ d.reportError(entry, err)
242241 }
243242
244243 // 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
300299 func (d *Daemon) deliverGlobal(n domain.Notification) {
301300 d.mu.Lock()
302301 d.notices.routingMu.Lock()
303- sessions := make([]attachmentSession, 0, len(d.sessions))
302+ sessions := make([]*session, 0, len(d.sessions))
304303 for _, entry := range d.sessions {
305304 sessions = append(sessions, entry)
306305 }
@@ -311,7 +310,10 @@ func (d *Daemon) deliverGlobal(n domain.Notification) {
311310 }
312311 targets := make([]*attachedClient, 0, len(sessions))
313312 for _, s := range sessions {
314- for _, ac := range snapshotAttachmentSession(s) {
313+ if s == nil {
314+ continue
315+ }
316+ for _, ac := range s.snapshotAttachments() {
315317 if !d.publishToast(ac, n) {
316318 continue
317319 }
M internal/usecase/daemon/notify_test.go +1 -1
@@ -52,7 +52,7 @@ func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.
5252
5353 func TestMalformedClientNoticeIsIgnored(t *testing.T) {
5454 d, sess, ac, _ := newNoticeFixture(t, &noticeClock{})
55- d.attachCoordinator(sess, ac, true)
55+ d.attachCoordinator(sess, nil, ac, true)
5656 tr, ok := ac.tr.(*portsmocks.MockTransport)
5757 require.True(t, ok, "attached client transport must be a MockTransport")
5858 frames := []ports.Frame{
M internal/usecase/daemon/output_state_stream.go +6 -3
@@ -71,7 +71,6 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
7171 maxOutstanding: uint64(window),
7272 }
7373 stream.maxOutstandingAtomic.Store(uint64(window))
74- stream.publishOutstanding()
7574 return stream
7675 }
7776
@@ -287,8 +286,8 @@ func (s *outputStateStream) rebaseLocked() {
287286 s.next = 0
288287 s.acked = 0
289288 s.generation++
290- s.maxOutstandingAtomic.Store(s.maxOutstanding)
291289 s.publishOutstanding()
290+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
292291 s.renderer.Reset()
293292 s.forceSnapshot = true
294293 s.initialized = false
@@ -317,7 +316,11 @@ func (s *outputStateStream) atCapacity() bool {
317316 if s == nil {
318317 return false
319318 }
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
321324 }
322325
323326 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 ...
150150 if entry == nil {
151151 return
152152 }
153- sess, local := localSession(entry)
153+ sess := entry
154154 var cmd command.Command
155155 var sessionTarget palette.Result
156156 var hasSessionTarget bool
@@ -276,10 +276,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
276276 var err error
277277 if effect != nil {
278278 err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
279- } else if local {
280- err = d.switchToTarget(sess, ac, target)
281279 } else {
282- err = errAttachmentTransition
280+ err = d.switchToTarget(sess, ac, target)
283281 }
284282 if errors.Is(err, errAttachmentTransition) {
285283 return
@@ -317,12 +315,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
317315 }
318316 return
319317 }
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- }
326318 attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
327319 if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
328320 return
@@ -401,7 +393,7 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
401393 type paletteExec struct {
402394 d *Daemon
403395 sess *session
404- attachment attachmentSession
396+ attachment *session
405397 ac *attachedClient
406398 recent []recentSession
407399 actions daemonActionRunner
@@ -435,7 +427,7 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
435427 }
436428
437429 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()})
439431 }
440432
441433 func (e paletteExec) CreateSession() error {
@@ -445,11 +437,10 @@ func (e paletteExec) CreateSession() error {
445437 }
446438 e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
447439 if token.ac == nil {
448- sess, ok := e.localSession()
449- if !ok {
440+ if e.sess == nil {
450441 return errAttachmentTransition
451442 }
452- return e.d.createSessionAndSwitch(sess, e.ac, name)
443+ return e.d.createSessionAndSwitch(e.sess, e.ac, name)
453444 }
454445 return e.d.createSessionAndSwitchForAttachment(token, name)
455446 })
@@ -633,14 +624,6 @@ func (e paletteExec) YankLastNotification() error {
633624 return nil
634625 }
635626
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-
644627 func (e paletteExec) JumpRecentSession(rank int) error {
645628 if rank < 1 || rank > len(e.recent) {
646629 return command.ErrInvalidArguments
@@ -656,8 +639,11 @@ func (e paletteExec) JumpRecentSession(rank int) error {
656639 if e.effect != nil {
657640 err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
658641 } else {
659- sess, ok := e.localSession()
660- if !ok {
642+ sess := e.attachment
643+ if sess == nil {
644+ sess = e.sess
645+ }
646+ if sess == nil {
661647 return errAttachmentTransition
662648 }
663649 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
10831083 compose: func() { fixture.renderCompositions++ },
10841084 emit: func() { fixture.renderEmissions++ },
10851085 }
1086- d.attachCoordinator(sess, ac, true)
1086+ d.attachCoordinator(sess, nil, ac, true)
10871087
10881088 // Prime the real renderer shadow before measurements. Subsequent paints use
10891089 // 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() {
5151 // enterPicker preserves the existing navigation entry point. Navigation always
5252 // publishes its model, including an empty one; only move entry can fail for a
5353 // missing destination.
54-func (d *Daemon) enterPicker(sess attachmentSession, ac *attachedClient) {
54+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
5555 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
5656 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
5757 }
5858
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) {
6060 rt := ac.overlays
6161 rt.pickerMu.Lock()
6262 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
@@ -83,10 +83,13 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
8383
8484 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
8585 // 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) {
8787 d.mu.Lock()
88- sessions := make([]attachmentSession, 0, len(d.sessions))
88+ sessions := make([]*session, 0, len(d.sessions))
8989 for _, entry := range d.sessions {
90+ if entry == nil {
91+ continue
92+ }
9093 sessions = append(sessions, entry)
9194 }
9295 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -98,14 +101,14 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
98101 d.mu.Unlock()
99102
100103 for _, entry := range sessions {
101- if local, ok := localSession(entry); ok {
102- d.refreshSessionFocusedTitles(local)
104+ if entry != nil {
105+ d.refreshSessionFocusedTitles(entry)
103106 }
104107 }
105108 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
106109
107110 type liveSnapshot struct {
108- entry attachmentSession
111+ entry *session
109112 view sessionView
110113 }
111114 // 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
116119 var current picker.SourceFilter
117120 for _, entry := range sessions {
118121 if entry == cur && ac != nil {
119- if local, ok := localSession(entry); ok {
120- local.repairAttachmentView(ac)
121- }
122+ entry.repairAttachmentView(ac)
122123 }
123124 snap := entry.snapshotView(opts)
124125 if entry == cur {
125- if _, ok := localSession(entry); ok && ac != nil {
126+ if ac != nil {
126127 view := ac.viewSnapshot()
127128 if view.tabID != "" {
128129 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
197198 return views, current
198199 }
199200
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 {
201202 views, attachedCurrent := d.pickerViews(cur, ac)
202203 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
203204 current = attachedCurrent
@@ -346,11 +347,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
346347 var err error
347348 if len(effects) != 0 && effects[0] != nil {
348349 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)
352350 } else {
353- err = errAttachmentTransition
351+ d.closePicker(ac)
352+ err = d.switchToTarget(sess, ac, target)
354353 }
355354 if errors.Is(err, errAttachmentTransition) {
356355 return
@@ -409,7 +408,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
409408 if targetSess == ac.currentAttachmentSession() {
410409 return
411410 }
412- rc := d.attachCoordinator(targetSess, nil, false)
411+ rc := d.attachCoordinator(targetSess, nil, nil, false)
413412 rc.subscribePreviewFor(ac, generation, func(renderWake) {
414413 if pickerPreviewCurrent(ac, targetSess, next, generation) {
415414 if owner := ac.currentAttachmentSession(); owner != nil {
@@ -479,9 +478,8 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
479478 }
480479 d.mu.Lock()
481480 var clients []*attachedClient
482- for _, entry := range d.sessions {
483- sess, ok := localSession(entry)
484- if !ok {
481+ for _, sess := range d.sessions {
482+ if sess == nil {
485483 continue
486484 }
487485 sess.mu.Lock()
@@ -556,7 +554,7 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
556554 return *left == *right
557555 }
558556
559-func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
557+func (d *Daemon) sessionByID(id domain.SessionID) *session {
560558 d.mu.Lock()
561559 defer d.mu.Unlock()
562560 return d.sessions[id]
@@ -605,9 +603,9 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
605603 return nil
606604 }
607605 d.mu.Lock()
608- targetSess, ok := localSession(d.sessions[target.Session])
606+ targetSess := d.sessions[target.Session]
609607 d.mu.Unlock()
610- if !ok {
608+ if targetSess == nil {
611609 if !token.attachmentCurrent() {
612610 return nil
613611 }
@@ -667,11 +665,10 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
667665 }
668666 token.effect.bindActionEnd(d, action)
669667 token.effect.End()
670- sess, ok := localSession(token.sess)
671- if !ok {
668+ if token.sess == nil {
672669 return errAttachmentTransition
673670 }
674- return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
671+ return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
675672 }
676673
677674 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
@@ -755,7 +752,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
755752 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
756753 }
757754 }
758- } else if active, ok := localSession(d.sessions[target.Session]); ok {
755+ } else if active := d.sessions[target.Session]; active != nil {
759756 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
760757 }
761758 d.mu.Unlock()
@@ -1031,9 +1028,9 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
10311028 return d.killPickerTarget(target)
10321029 }
10331030 d.mu.Lock()
1034- targetSess, ok := localSession(d.sessions[target.Session])
1031+ targetSess := d.sessions[target.Session]
10351032 d.mu.Unlock()
1036- if !ok {
1033+ if targetSess == nil {
10371034 return nil
10381035 }
10391036 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -1048,9 +1045,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
10481045 return nil
10491046 }
10501047 d.mu.Lock()
1051- targetSess, ok := localSession(d.sessions[target.Session])
1048+ targetSess := d.sessions[target.Session]
10521049 d.mu.Unlock()
1053- if ok {
1050+ if targetSess != nil {
10541051 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
10551052 }
10561053 return nil
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1550,7 +1550,7 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
15501550 current.mu.Lock()
15511551 current.registerAttachmentLocked(ac)
15521552 current.mu.Unlock()
1553- require.NotNil(t, d.attachCoordinator(current, ac, true))
1553+ require.NotNil(t, d.attachCoordinator(current, nil, ac, true))
15541554
15551555 d.enterPicker(current, ac)
15561556 d.handlePickerInput(ac, []byte("k"))
M internal/usecase/daemon/prompt.go +2 -2
@@ -13,7 +13,7 @@ func promptModalFor(title string) ui.Modal {
1313 return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
1414 }
1515
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) {
1717 d.closePrompt(ac)
1818 ac.overlays.promptMu.Lock()
1919 ac.overlays.prompt = promptui.New(title, initial)
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
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) {
2727 d.closePrompt(ac)
2828 ac.overlays.promptMu.Lock()
2929 ac.overlays.prompt = promptui.New(title, initial)
M internal/usecase/daemon/recent_sessions.go +2 -2
@@ -17,14 +17,14 @@ type recentSession struct {
1717
1818 // recentSessions returns the current session's capped named-session MRU list.
1919 // 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 {
2121 if d == nil {
2222 return nil
2323 }
2424 d.mu.Lock()
2525 recent := make([]recentSession, 0, len(d.sessions))
2626 for _, sess := range d.sessions {
27- if sess == current {
27+ if sess == nil || sess == current {
2828 continue
2929 }
3030 snap := sess.snapshotView(viewOptions{})
M internal/usecase/daemon/render.go +30 -13
@@ -60,7 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
6060 return false
6161 }
6262 d.mu.Lock()
63- sessions := localSessionsSnapshot(d.sessions)
63+ sessions := sessionsSnapshot(d.sessions)
6464 d.mu.Unlock()
6565 for _, sess := range sessions {
6666 for _, ac := range sess.snapshotAttachments() {
@@ -290,17 +290,26 @@ func (b *runtimeMarkBatch) flush() {
290290 }
291291 }
292292
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 {
294302 // Session-owned PTY preparation remains local; attachment rendering is
295- // captured through attachmentSession.captureRenderState.
296- sess, local := localSession(entry)
303+ // captured through session.captureRenderState.
304+ sess := entry
305+ local := sess != nil
297306 marks := d.newRuntimeMarkBatch()
298307 if lease != nil {
299308 token := attachmentToken(entry, ac, ac.transport())
300309 token.lease = lease
301310 ticket, admitted := ac.beginAttachmentEffect(token)
302311 if !admitted {
303- return
312+ return paintRejected
304313 }
305314 marks.attachmentEffect = ticket
306315 defer ticket.End()
@@ -309,7 +318,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
309318 if local {
310319 tb = sess.tabForAttachment(ac)
311320 if tb == nil {
312- return
321+ return paintRejected
313322 }
314323 }
315324
@@ -322,23 +331,30 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
322331 entry.core().mu.Unlock()
323332 if !owned || ac.currentAttachmentSession() != entry {
324333 ac.sendMu.Unlock()
325- return
334+ return paintRejected
326335 }
327336 if lease != nil {
328337 rc := attachmentRenderCoordinator(entry)
329338 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
330339 ac.sendMu.Unlock()
331- return
340+ return paintRejected
332341 }
333342 }
334- // Capacity is checked before any destructive capture. The coordinator is
335- // the normal gate, but this ownership check also protects direct resize and
336- // test paint paths from consuming damage that cannot be emitted.
343+ // Capacity is checked before any destructive capture. Refresh the atomic
344+ // readiness snapshot while sendMu is held so direct test/setup mutations of
345+ // the stream fields remain covered without making coordinator probes wait on
346+ // a blocked transport send.
347+ if ac.output != nil {
348+ ac.output.syncCapacityLocked()
349+ }
350+ // The coordinator is the normal gate, but this ownership check also protects
351+ // direct resize and test paint paths from consuming damage that cannot be
352+ // emitted.
337353 if ac.output != nil && ac.output.atCapacity() {
338354 // The coordinator owns the blocked interval; this guard only protects
339355 // direct test/resize paint calls from destructive capture.
340356 ac.sendMu.Unlock()
341- return
357+ return paintBlockedCapacity
342358 }
343359 // Composition owns attachment sendMu; initialize its lazy overlay state
344360 // 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,
418434 endCapture(0, ok)
419435 if !ok {
420436 ac.sendMu.Unlock()
421- return
437+ return paintRejected
422438 }
423439 if ac.renderStages.capture != nil {
424440 ac.renderStages.capture()
@@ -437,6 +453,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
437453 ac.renderStages.compose()
438454 }
439455 d.emitFrame(entry, ac, state, composed, &marks)
456+ return paintEmitted
440457 }
441458
442459 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(
344344 }
345345
346346 // captureRenderState preserves the focused local test seam. Production
347-// rendering delegates through attachmentSession.captureRenderState.
347+// rendering delegates through *session.captureRenderState.
348348 func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349349 return captureLocalRenderState(sess, ac, request)
350350 }
M internal/usecase/daemon/render_coordinator.go +86 -9
@@ -39,9 +39,6 @@ type renderCoordinator struct {
3939 // invalidations retain only their exact target lease here.
4040 pendingShared bool
4141 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
4542 // ackBlocked retains the first rejected ACK-capacity probe until the
4643 // matching consume or lifecycle closure releases that exact interval.
4744 ackBlocked *ackBlockedSpan
@@ -316,6 +313,81 @@ func (c *renderCoordinator) notifyAckForLease(lease *attachmentLease) {
316313 c.fire(gen, false, false)
317314 }
318315
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+
319391 // noteSyncBegin records a synchronized-output batch for its stable pane and
320392 // arms that pane's watchdog. Overlapping pane batches are independent.
321393 // markDeadlineDue records a received timer tick before the worker probes ACK
@@ -807,7 +879,7 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
807879 // invalidateRender is the sole producer fan-in. In tests and transitional
808880 // headless paths without an attached coordinator it retains the old private
809881 // 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) {
811883 if rc := attachmentRenderCoordinator(entry); rc != nil {
812884 invalidation := renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}
813885 if rc.invalidateForAttachment(ac, invalidation) {
@@ -832,7 +904,7 @@ func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, r
832904 // invalidateRenderNow publishes through the coordinator but immediately
833905 // flushes the wake when the client can accept state. Attach uses this path so
834906 // 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) {
836908 if rc := attachmentRenderCoordinator(entry); rc != nil {
837909 if rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) {
838910 rc.fireCurrent(false)
@@ -880,8 +952,8 @@ func (c *renderCoordinator) fireWithTimerToken(token *timerToken, gen uint64, wa
880952 }
881953
882954 func (c *renderCoordinator) attachmentAckReady(ac *attachedClient) bool {
883- if c.ackReadyFor != nil {
884- return c.ackReadyFor(ac)
955+ if c.opts.ackReadyFor != nil {
956+ return c.opts.ackReadyFor(ac)
885957 }
886958 if c.opts.ackReady != nil {
887959 return c.opts.ackReady()
@@ -1013,7 +1085,11 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10131085 if len(leases) == 0 && legacyOutput {
10141086 allDelivered = true
10151087 }
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}
10171093 if syncLease != nil {
10181094 if selected, ok := attachmentLeases[syncLease.attachment]; ok && selected == syncLease {
10191095 w.lease = syncLease
@@ -1043,11 +1119,12 @@ func (c *renderCoordinator) fireWithTimerTokenAndLease(token *timerToken, gen ui
10431119 }
10441120
10451121 if outputReady {
1046- for ac := range attachmentLeases {
1122+ for ac, lease := range attachmentLeases {
10471123 if c.delivered == nil {
10481124 c.delivered = make(map[*attachedClient]uint64)
10491125 }
10501126 c.delivered[ac] = c.pendingGeneration
1127+ lease.deliveredGeneration = c.pendingGeneration
10511128 }
10521129 }
10531130 if len(leases) != 0 {
M internal/usecase/daemon/render_coordinator_support.go +2 -2
@@ -15,7 +15,7 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics
1515
1616 // attachmentRenderCoordinator returns the coordinator installed for the exact
1717 // attachment-session identity.
18-func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
18+func attachmentRenderCoordinator(entry *session) *renderCoordinator {
1919 if entry == nil || entry.core() == nil {
2020 return nil
2121 }
@@ -24,7 +24,7 @@ func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
2424
2525 // installAttachmentRenderCoordinator publishes rc for the exact
2626 // attachment-session identity.
27-func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {
27+func installAttachmentRenderCoordinator(entry *session, rc *renderCoordinator) {
2828 if entry == nil || entry.core() == nil {
2929 return
3030 }
M internal/usecase/daemon/render_coordinator_test.go +4 -4
@@ -195,6 +195,7 @@ func awaitWake(t *testing.T, ch chan renderWake) renderWake {
195195 t.Helper()
196196 wake := awaitTestValue(t, ch, "coordinator did not publish a wake after fake-clock advancement")
197197 wake.attachmentLeases = nil
198+ wake.generation = 0
198199 return wake
199200 }
200201
@@ -404,12 +405,11 @@ func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
404405 func TestRenderCoordinatorAckReadinessIsAttachmentScoped(t *testing.T) {
405406 slow, healthy := &attachedClient{}, &attachedClient{}
406407 var slowReady atomic.Bool
407- slowReady.Store(false)
408408 wakes := make(chan renderWake, 2)
409409 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 },
411412 })
412- rc.ackReadyFor = func(ac *attachedClient) bool { return ac == healthy || slowReady.Load() }
413413 rc.attach(slow)
414414 rc.attach(healthy)
415415 rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
@@ -1403,7 +1403,7 @@ func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T
14031403 }
14041404
14051405 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) {
14071407 for _, transition := range []struct {
14081408 name string
14091409 run func(*renderCoordinator, *attachedClient)
M internal/usecase/daemon/render_coordinator_types.go +14 -4
@@ -51,6 +51,9 @@ type renderWake struct {
5151 reset bool
5252 urgent bool
5353 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
5457 // lease is optional coordinator-owned lifecycle safety for an attachment
5558 // specific wake. Shared wakes leave it nil and snapshot live attachment
5659 // leases at dispatch time; each paint then revalidates its exact connection
@@ -68,8 +71,12 @@ type attachmentLease struct {
6871 attachment *attachedClient
6972 ready bool
7073 active bool
71- // A lease is an immutable callback capability. Its lifecycle bits are
72- // changed only under renderCoordinator.mu; callbacks revalidate it at each
74+ // deliveredGeneration records the latest wake that reached this lease. It
75+ // outlives the coordinator's coalesced delivered map so an older capacity
76+ // failure cannot overwrite a newer attempt.
77+ deliveredGeneration uint64
78+ // A lease is an immutable callback capability. Its lifecycle and delivery
79+ // bits are changed only under renderCoordinator.mu; callbacks revalidate it at each
7380 // short effect boundary and never retain coordinator ownership while routing
7481 // arbitrary handlers.
7582 }
@@ -95,8 +102,11 @@ type renderCoordinatorOptions struct {
95102 // wake composes a coalesced render request.
96103 wake func(renderWake)
97104 // ackReady reports whether the attachment may compose another output
98- // state (the outputStateStream window has capacity).
99- ackReady func() bool
105+ // state (the outputStateStream window has capacity). It is retained for
106+ // coordinator-only tests and headless callers; production uses ackReadyFor
107+ // so one attachment cannot gate another.
108+ ackReady func() bool
109+ ackReadyFor func(*attachedClient) bool
100110 // syncRenderable reports whether a pane's synchronized batch currently
101111 // contributes to the attached composition. It must not acquire c.mu.
102112 syncRenderable func(*pane) bool
M internal/usecase/daemon/render_damage_receipt_test.go +2 -2
@@ -134,7 +134,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
134134 // Prepare failures now schedule recovery through the coordinator;
135135 // retain the failed transaction here until this test explicitly retries.
136136 d.clock = newNoticeClock()
137- d.attachCoordinator(sess, ac, true)
137+ d.attachCoordinator(sess, nil, ac, true)
138138 healthy := ac.transport()
139139 p := sess.tabs[0].focusedPane()
140140
@@ -170,7 +170,7 @@ func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T
170170 func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
171171 clock := newNoticeClock()
172172 d, sess, ac, _ := newNoticeFixture(t, clock)
173- rc := d.attachCoordinator(sess, ac, true)
173+ rc := d.attachCoordinator(sess, nil, ac, true)
174174 var producers []string
175175 rc.opts.onInvalidate = func(inv renderInvalidation) {
176176 // 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) {
428428
429429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430430 // 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 {
432432 var ownedMarks runtimeMarkBatch
433433 var marks *runtimeMarkBatch
434434 if len(batches) != 0 {
@@ -480,17 +480,15 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
480480 // Without a coordinator reportError repaints synchronously. Suppress only
481481 // that nested notice repaint; leave the guard before returning so a later,
482482 // independent failed transaction can still notify the user.
483- if sess, ok := localSession(entry); ok {
484- if sess.renderCoordinator() == nil {
485- if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
486- return true
487- }
488- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
489- ac.prepareFailureFallback.Store(false)
483+ if entry.renderCoordinator() == nil {
484+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
490485 return true
491486 }
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
493490 }
491+ d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
494492 // Notices are session-scoped, while the attachment repaint still needs
495493 // to redraw its chrome after a failed transaction.
496494 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
@@ -563,9 +561,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
563561 // admission first. Detachment freezes the gate and therefore cannot mutate
564562 // ownership until any enclosing admitted operation has also ended.
565563 if marks.attachmentEffect == nil {
566- if sess, ok := localSession(entry); ok {
567- d.detachOnSendError(sess, ac, sendTr)
568- }
564+ d.detachOnSendError(entry, ac, sendTr)
569565 } else {
570566 // Capture the exact admitted capability, including its coordinator
571567 // 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) {
313313 d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
314314 clock := newCoordinatorMockClock(t, 8)
315315 d.clock = clock.clock
316- rc := d.attachCoordinator(sess, ac, true)
316+ rc := d.attachCoordinator(sess, nil, ac, true)
317317
318318 d.sessWg.Add(1)
319319 go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
@@ -349,7 +349,7 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
349349 d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
350350 clock := newCoordinatorMockClock(t, 8)
351351 d.clock = clock.clock
352- rc := d.attachCoordinator(sess, ac, true)
352+ rc := d.attachCoordinator(sess, nil, ac, true)
353353
354354 d.sessWg.Add(2)
355355 go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
@@ -385,7 +385,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
385385 d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
386386 clock := newCoordinatorMockClock(t, 8)
387387 d.clock = clock.clock
388- rc := d.attachCoordinator(target, targetClient, true)
388+ rc := d.attachCoordinator(target, nil, targetClient, true)
389389
390390 // A viewer in a different session is previewing the headless target.
391391 viewer := &attachedClient{}
@@ -429,7 +429,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
429429 d, target, detached, _ := newManualSessionWithPTYs(t, pty)
430430 clock := newCoordinatorMockClock(t, 8)
431431 d.clock = clock.clock
432- rc := d.attachCoordinator(target, detached, true)
432+ rc := d.attachCoordinator(target, nil, detached, true)
433433
434434 secondTransport, secondSends := newCapturingTransport(t)
435435 remaining := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: detached.size}
@@ -470,7 +470,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
470470 d, target, client, _ := newManualSessionWithPTYs(t, pty)
471471 clock := newCoordinatorMockClock(t, 8)
472472 d.clock = clock.clock
473- rc := d.attachCoordinator(target, client, true)
473+ rc := d.attachCoordinator(target, nil, client, true)
474474 wakes := make(chan renderWake, 1)
475475 rc.opts.wake = func(w renderWake) { wakes <- w }
476476 target.mu.Lock()
M internal/usecase/daemon/render_timer.go +12 -3
@@ -66,15 +66,24 @@ func (l *timerLane) clearLocked(token *timerToken) bool {
6666 type timerSupervisor struct{ workers sync.WaitGroup }
6767
6868 func (s *timerSupervisor) startLocked(token *timerToken, timerC <-chan time.Time, fire func()) {
69- s.workers.Add(1)
70- go func() {
71- defer s.workers.Done()
69+ s.startTaskLocked(func() {
7270 defer close(token.done)
7371 select {
7472 case <-timerC:
7573 fire()
7674 case <-token.cancel:
7775 }
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()
7887 }()
7988 }
8089
M internal/usecase/daemon/resume.go +3 -6
@@ -316,12 +316,9 @@ func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
316316 d.mu.Lock()
317317 defer d.mu.Unlock()
318318 token := ac.resumeClaimToken
319- if token == 0 {
320- return true
321- }
322319 parked := d.parked[token]
323- if parked == nil || parked.ac != ac || !parked.claimed {
324- return false
320+ if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
321+ return token == 0
325322 }
326323 delete(d.parked, token)
327324 parked.claimed = false
@@ -612,7 +609,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612609 // mandatory first paint cannot be blocked by ACKs that died with the link.
613610 ac.rebaseOutput()
614611 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
615- ac.output.syncCapacityLocked()
612+ ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
616613 ac.replaceTransport(tr)
617614 ac.size = sz
618615 ac.resumeToken = d.nextResumeTokenLocked()
M internal/usecase/daemon/resume_test.go +1 -1
@@ -874,7 +874,7 @@ func TestLiveResumeRejectsLateMarkerAfterTerminalCleanupWins(t *testing.T) {
874874 }
875875
876876 // TestMarkParkingInFlightRequiresExactLiveOwnership covers lock-safe rejection
877-// when the attachment is no longer the exact active or snatched owner, while
877+// when the attachment is no longer the exact registered owner, while
878878 // still advertising for both live roles.
879879 func TestStaleClientGoneAfterTransportCheckDoesNotDetachReboundAttachment(t *testing.T) {
880880 pty, release := newBlockingPTY(t)
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -69,7 +69,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6969
7070 func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testing.T) {
7171 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
72- rc := d.attachCoordinator(sess, ac, true)
72+ rc := d.attachCoordinator(sess, nil, ac, true)
7373 oldLease := rc.attachmentLease(ac)
7474 require.NotNil(t, oldLease)
7575
M internal/usecase/daemon/session.go +18 -27
@@ -155,7 +155,7 @@ type tab struct {
155155 // what it has actually seen). sendMu serialises the two senders — the render
156156 // coordinator and the connection handler — so the transport's single-writer
157157
158-func (d *Daemon) touchMRU(entry attachmentSession) {
158+func (d *Daemon) touchMRU(entry *session) {
159159 if d == nil || entry == nil || entry.core() == nil {
160160 return
161161 }
@@ -170,15 +170,11 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170170 break
171171 }
172172 }
173- sess, ok := localSession(entry)
174- if !ok {
175- return
176- }
177- sess.mu.Lock()
178- if !sess.ephemeral {
179- d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
173+ entry.mu.Lock()
174+ if !entry.ephemeral {
175+ d.markCatalogueDirty(entry.persistRecordLocked(max(d.nowUnixNano(), entry.createdAt, int64(1))).MetadataUpdate())
180176 }
181- sess.mu.Unlock()
177+ entry.mu.Unlock()
182178 }
183179
184180 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
474470 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475471 copySourceEnvironment: true, ready: true,
476472 createTargetLocked: func() (*session, error) {
477- source, ok := localSession(token.sess)
478- if !ok {
473+ source := token.sess
474+ if source == nil {
479475 return nil, errAttachmentTransition
480476 }
481477 if d.closing {
@@ -500,8 +496,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500496 }
501497
502498 d.touchMRU(created)
503- if source, ok := localSession(token.sess); ok {
504- token.ac.recordPreviousSession(source)
499+ if token.sess != nil {
500+ token.ac.recordPreviousSession(token.sess)
505501 }
506502 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
507503 d.deferAttachmentTransitionCleanups(transition)
@@ -513,7 +509,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
513509 return d.createTabForAttachment(sess, nil, sz)
514510 }
515511
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 {
517513 sess.mu.Lock()
518514 name := sess.name
519515 cwd := sess.cwd
@@ -521,7 +517,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
521517 env := copyEnvironment(sess.env)
522518 attachments := sess.snapshotAttachmentsLocked()
523519 sess.mu.Unlock()
524- tbSize := contentSize(sz)
520+ tbSize := contentSize(sess.fullViewportSize())
525521 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
526522 if err != nil {
527523 return err
@@ -774,10 +770,7 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
774770 // own an independent connection lifecycle and detach through the same exact
775771 // transport/generation fence.
776772 if token.lease == nil {
777- if sess, ok := localSession(token.sess); ok {
778- return d.detachIfCurrentTransport(sess, token.ac, token.transport)
779- }
780- return false
773+ return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
781774 }
782775 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
783776 defer frozen.unfreeze()
@@ -1152,9 +1145,8 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11521145 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
11531146 return sessionKillParticipants{}, false
11541147 }
1155- var ok bool
1156- snapshot.source, ok = localSession(token.sess)
1157- if !ok {
1148+ snapshot.source = token.sess
1149+ if snapshot.source == nil {
11581150 return sessionKillParticipants{}, false
11591151 }
11601152 snapshot.sourceToken = &admission.token
@@ -1641,8 +1633,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
16411633 // d.mu.
16421634 func (d *Daemon) allocEphemeralNameLocked() string {
16431635 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1644- for _, entry := range d.sessions {
1645- if s, ok := localSession(entry); ok {
1636+ for _, s := range d.sessions {
1637+ if s != nil {
16461638 used[s.name] = struct{}{}
16471639 }
16481640 }
@@ -1658,9 +1650,8 @@ func (d *Daemon) allocEphemeralNameLocked() string {
16581650 }
16591651
16601652 func (d *Daemon) findByNameLocked(name string) *session {
1661- for _, entry := range d.sessions {
1662- s, ok := localSession(entry)
1663- if ok && s.name == name {
1653+ for _, s := range d.sessions {
1654+ if s != nil && s.name == name {
16641655 return s
16651656 }
16661657 }
M internal/usecase/daemon/session_back.go +3 -3
@@ -45,11 +45,11 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
4545
4646 // clearPreviousSessionIf clears target only if it has not been replaced since
4747 // 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) {
4949 if ac == nil {
5050 return
5151 }
52- ac.previousSession.With(func(previous *attachmentSession) {
52+ ac.previousSession.With(func(previous **session) {
5353 if *previous == target {
5454 *previous = nil
5555 }
@@ -59,7 +59,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
5959 // recordPreviousSession is called only after a cross-session hand-off has
6060 // committed. Keeping this after the commit makes failed/displaced switches
6161 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {
62+func (ac *attachedClient) recordPreviousSession(origin *session) {
6363 if ac != nil && origin != nil {
6464 ac.previousSession.Set(origin)
6565 }
M internal/usecase/daemon/session_core.go +6 -23
@@ -7,16 +7,6 @@ import (
77 "github.com/bnema/vev/internal/domain"
88 )
99
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-
2010 // sessionCore is the state shared by every attachment-capable session. It is
2111 // embedded as the first field of session so the existing s.mu selector remains
2212 // the one lock guarding both attachment membership and local tab state.
@@ -60,19 +50,12 @@ func (s *session) validTargetTabLocked(tabIndex int) bool {
6050 return tabIndex < len(s.tabs)
6151 }
6252
63-// localSession narrows an attachment entry for operations that own local PTYs,
64-// tabs, snapshots, or durable lifecycle state.
65-func localSession(entry attachmentSession) (*session, bool) {
66- sess, ok := entry.(*session)
67- return sess, ok && sess != nil
68-}
69-
70-func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session {
71- locals := make([]*session, 0, len(entries))
72- for _, entry := range entries {
73- if sess, ok := localSession(entry); ok {
74- locals = append(locals, sess)
53+func sessionsSnapshot(entries map[domain.SessionID]*session) []*session {
54+ sessions := make([]*session, 0, len(entries))
55+ for _, sess := range entries {
56+ if sess != nil {
57+ sessions = append(sessions, sess)
7558 }
7659 }
77- return locals
60+ return sessions
7861 }
M internal/usecase/daemon/session_core_test.go +4 -6
@@ -30,7 +30,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3030
3131 core := sess.core()
3232 require.Same(t, &sess.sessionCore, core)
33- require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
3433 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
3534
3635 core.mu.Lock()
@@ -113,7 +112,7 @@ func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
113112 }
114113
115114 func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
116- d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)}
115+ d := &Daemon{sessions: make(map[domain.SessionID]*session)}
117116 first := &session{}
118117 first.id = domain.SessionID("same")
119118 second := &session{}
@@ -146,9 +145,8 @@ func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
146145 second.core().mu.Unlock()
147146 }
148147
149-func mustLocalSession(t *testing.T, entry attachmentSession) *session {
148+func mustLocalSession(t *testing.T, entry *session) *session {
150149 t.Helper()
151- sess, ok := localSession(entry)
152- require.True(t, ok)
153- return sess
150+ require.NotNil(t, entry)
151+ return entry
154152 }
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
3838 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
3939 require.True(t, ok)
4040 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}
4242
4343 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
4444 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) {
1616 store, state := newMockStore(t)
1717 WithStore(t, store)(d)
1818 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}
2020 sess.mu.Lock()
2121 record := sess.persistRecordLocked(1)
2222 sess.mu.Unlock()
M internal/usecase/daemon/status.go +8 -15
@@ -240,24 +240,21 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
240240 // palette interaction has captured recent-session hints. Normal palette state
241241 // continues to use the canonical, live MRU list.
242242 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
243- var entry attachmentSession
243+ var entry *session
244244 if cur != nil {
245245 entry = cur
246246 }
247247 return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
248248 }
249249
250-// barStateForAttachmentPaletteHints composes daemon-owned chrome for either
251-// attachment implementation. Bar scripts remain local-session-only because
250+// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
251+// attachment. Bar scripts remain local-session-only because
252252 // 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 {
254254 return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, recent)
255255 }
256256
257-func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
258- if attachmentSessionCore(cur) == nil {
259- cur = nil
260- }
257+func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
261258 ranked := rankedRecentForHints(hints, recent)
262259 state := barState{statusFeedback: statusFeedback}
263260 if d != nil {
@@ -268,14 +265,10 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur attachmentSession, ac
268265 if d != nil {
269266 includeTerminalTitle = d.currentTabsConfig().TerminalTitle
270267 }
271- if local, ok := localSession(cur); ok {
272- state.status = local.statusSegmentsFor(ac, includeTerminalTitle)
273- } else {
274- state.status = cur.statusSegments(includeTerminalTitle)
275- }
268+ state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
276269 }
277- if local, ok := localSession(cur); ok && d != nil {
278- state.topRight, state.bottomRight = d.barScriptSnapshot(local)
270+ if cur != nil && d != nil {
271+ state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
279272 }
280273 if ranked != nil {
281274 state.rankedRecent = ranked
M internal/usecase/daemon/status_test.go +3 -18
@@ -22,20 +22,6 @@ import (
2222 "github.com/bnema/vev/pkg/renderer"
2323 )
2424
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-
3925 // stubClock returns timers whose channel never fires, so a scheduler under it
4026 // blocks in its debounce loop until the session context is cancelled. Used by
4127
@@ -931,18 +917,17 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
931917 }
932918
933919 func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
934- var nilEntry attachmentSession
920+ var nilEntry *session
935921 var typedNil *session
936922 valid := &session{sessionCore: sessionCore{id: "valid"}}
937923
938924 for _, tc := range []struct {
939925 name string
940- a attachmentSession
941- b attachmentSession
926+ a *session
927+ b *session
942928 }{
943929 {name: "nil interfaces", a: nilEntry, b: nilEntry},
944930 {name: "typed nil", a: typedNil, b: valid},
945- {name: "core nil", a: &coreNilAttachment{}, b: valid},
946931 } {
947932 t.Run(tc.name, func(t *testing.T) {
948933 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
848848 if ac == nil {
849849 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
850850 }
851- rc := d.attachCoordinator(sess, ac, true)
851+ rc := d.attachCoordinator(sess, nil, ac, true)
852852 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
853853 }
854854
M internal/usecase/daemon/transactional_resize_test.go +4 -4
@@ -410,7 +410,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
410410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411411 observer := &daemonRuntimeObserver{}
412412 d.runtimeObserver = observer
413- rc := d.attachCoordinator(sess, ac, true)
413+ rc := d.attachCoordinator(sess, nil, ac, true)
414414 lease := rc.attachmentLease(ac)
415415 var newer uint64
416416 d.beforeSessionResizePublication = func() {
@@ -471,7 +471,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
471471 t.Run(tc.name, func(t *testing.T) {
472472 pty := &transactionalResizePTY{}
473473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474- rc := d.attachCoordinator(sess, ac, true)
474+ rc := d.attachCoordinator(sess, nil, ac, true)
475475 lease := rc.attachmentLease(ac)
476476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477477 require.NotZero(t, epoch)
@@ -555,7 +555,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
555555 tb.mu.Unlock()
556556 owner := publishPaneOwner(popup, source, tb, 7)
557557
558- rc := d.attachCoordinator(source, ac, true)
558+ rc := d.attachCoordinator(source, nil, ac, true)
559559 lease := rc.attachmentLease(ac)
560560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
@@ -780,7 +780,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
780780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781781 tb.mu.Unlock()
782782
783- rc := d.attachCoordinator(sess, ac, true)
783+ rc := d.attachCoordinator(sess, nil, ac, true)
784784 lease := rc.attachmentLease(ac)
785785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786786 require.NotZero(t, epoch)
M internal/usecase/daemon/transport_observability_test.go +1 -1
@@ -20,7 +20,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
2020 // One in-flight state makes the second public resize take the ACK-blocked
2121 // path. The real coordinator owns both renders and the ACK wake.
2222 ac.output = newOutputStateStream(1)
23- rc := d.attachCoordinator(sess, ac, true)
23+ rc := d.attachCoordinator(sess, nil, ac, true)
2424
2525 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 90, Rows: 25}) {
2626 t.Fatal("first resize was not accepted")
M internal/usecase/daemon/wire.go +3 -1
@@ -7,13 +7,15 @@ import (
77 )
88
99 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
10+ s.mu.Lock()
1011 w := ports.Welcome{
1112 SessionID: string(s.id),
1213 SessionName: s.name,
1314 Ephemeral: s.ephemeral,
1415 Capabilities: ports.CapabilityResume,
15- ResumeToken: ac.resumeToken,
1616 }
17+ s.mu.Unlock()
18+ w.ResumeToken = ac.resumeToken
1719 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
1820 }
1921
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