a73x

909a0377

feat: two-client e2e and demo; docs: resize decision recorded — M5 complete

a73x   2026-08-08 14:08

Commit message
feat: two-client e2e and demo; docs: resize decision recorded — M5 complete

README.md
Old New
@@ -5,7 +5,13 @@ authoritatively in a daemon and replicated in the client — state sync
5 instead of escape-sequence replay. See `docs/handoff.md` for the design 5 instead of escape-sequence replay. See `docs/handoff.md` for the design
6 and `docs/decisions.md` for decisions made. 6 and `docs/decisions.md` for decisions made.
7 7
8 Status: **M4 — deltas.** 8 Status: **M5 — two clients.** All handoff milestones complete; all three
9 kill criteria cleared. The prototype's two founding questions are both
10 answered yes: ghostty-vt serves as an authoritative headless grid in a
11 daemon without forking it, and detach/reattach as state sync is correct
12 and fast — a killed client reattaches into a live full-screen `nvim`
13 session in ~5 ms from one snapshot, and steady-state delta traffic is 1%
14 of the snapshot-equivalent cost.
9 15
10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned 16 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned
11 toolchain, override with `make ZIG=...`. 17 toolchain, override with `make ZIG=...`.
@@ -18,8 +24,12 @@ toolchain, override with `make ZIG=...`.
18 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes 24 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes
19 make bench # typing-workload byte-ratio measurement 25 make bench # typing-workload byte-ratio measurement
20 26
27 Multiple mux clients may attach to one session; the grid follows the most
28 recent attacher/resize (latest wins).
29
21 Detach, or kill the client outright — the session survives and `mux` 30 Detach, or kill the client outright — the session survives and `mux`
22 resumes it from a state snapshot, including TUI screens and the primary 31 resumes it from a state snapshot, including TUI screens and the primary
23 screen behind them. Scrollback is fetched lazily (Shift+PageUp/PageDown 32 screen behind them. Scrollback is fetched lazily (Shift+PageUp/PageDown
24 pages; any other key returns to live). systemd user units (socket 33 pages; any other key returns to live). systemd user units (socket
25 activation) live in `contrib/`. 34 activation) live in `contrib/`; with `loginctl enable-linger` a session
35 also survives logout, though not a reboot.
docs/decisions.md
Old New
@@ -152,15 +152,113 @@
152 applier in protocol.zig so the server fidelity test exercises the real 152 applier in protocol.zig so the server fidelity test exercises the real
153 client apply path; Client struct decomposition of client.zig's attach 153 client apply path; Client struct decomposition of client.zig's attach
154 loop; resync_pending bound on the client's malformed-delta re-attach; 154 loop; resync_pending bound on the client's malformed-delta re-attach;
155 snapshot_prefix_len constant + read/write helpers; consistent 155 consistent malformed-frame policy (short snapshots currently ignored,
156 malformed-frame policy (short snapshots currently ignored, bad deltas 156 bad deltas resync); timer-based test deadlines + generalized poll
157 resync); timer-based test deadlines + generalized poll helper in 157 helper in server tests; DeltaTracker split into src/delta.zig; naming
158 server tests; DeltaTracker split into src/delta.zig; naming
159 (resyncSnapshot/sendResync, DeltaTracker.update); paintDelta golden 158 (resyncSnapshot/sendResync, DeltaTracker.update); paintDelta golden
160 test. 159 test; putU32/getU32 now-dead exports; client-side got_state refusal
160 branch lacks automated coverage; serviceClient's resync guard asymmetry;
161 automated coverage of the resize trigger for latest-wins with two
162 clients attached (the attach trigger is pinned by a test, the resize
163 trigger only by the manual demo).
164
165 ## 2026-08-07 (M5)
166
167 - **Resize policy: latest wins.** The authoritative grid follows the most
168 recent attach or resize event from any client (modern tmux
169 `window-size latest`). Rejected: smallest-wins (punishes the larger
170 screen for the smaller one's presence — the handoff calls the result
171 "widely disliked"); per-client reflow (needs a per-client engine or
172 reflow pass, contradicting the single-authoritative-grid architecture —
173 reconsider post-prototype only with a concrete need). Non-matching
174 clients render the grid clipped: autowrap off (DECAWM), rows beyond the
175 tty skipped, cursor clamped, no border art. Snapshots carry the grid
176 size (16-byte prefix); size changes always travel as snapshots, so
177 deltas stay size-free.
178 - **Multi-client: broadcast, up to 8.** Every attached client receives
179 every delta/snapshot; per-client write failure drops that client only.
180 Takeover is retired — attach joins; a full session refuses with
181 exit_status{1}. The taken_over frame stays in the protocol but is now
182 sent by nobody. This is not for wire-compat (binaries are lockstep, per
183 the prefix change below): the type byte is simply not reused, and the
184 client's handler for it is dead code that costs nothing to leave in.
185 - **Client-local view state is per-connection by construction** — scroll
186 mode lives in the client, scrollback fetches are served per-fd — and
187 pinned by a server test (one client pages history while the other
188 streams deltas).
189 - **Stats under broadcast:** delta_bytes/snapshot_bytes count actual
190 per-client sends; snapshot_equiv_bytes accrues once per update event
191 (unicast join snapshots also accrue). The bench (single client) is
192 unaffected.
193 - **Known costs and edges, accepted for the prototype:** blocking
194 per-client writes mean one stalled client can head-of-line-block all
195 others (fix = buffered/nonblocking writes, owed before any network
196 transport). Simultaneous connects funnel through 4 observer slots
197 before promotion, so >4 clients connecting in the same instant see
198 unexplained EOFs (connect-then-confirm serializes fine in practice). A
199 client's corrupt-delta resync re-attaches at its own size, which — by
200 latest-wins — resizes the session for everyone. Scroll paging divides
201 daemon history rows by the local tty height, so page counts are
202 approximate when sizes differ.
203 - **Snapshot prefix widening (12→16 bytes) is a breaking wire change**;
204 binaries must upgrade in lockstep (daemon restart on upgrade).
205 Consistent with the recorded no-epoch gap.
206 - **Daemon lifetime — resolved, closing the M2 open item, with the
207 reboot half answered honestly.** Sessions survive logout; they do not
208 survive reboot. The user units in `contrib/` cover the logout case:
209 `muxd.socket` starts the daemon on first connect and survives client
210 exits, and `loginctl enable-linger $USER` keeps the user manager alive
211 across logout and starts it again at boot. But no process survives a
212 reboot — after one, the socket unit spawns a *fresh* daemon and a fresh
213 shell, and the previous session's grid, scrollback, and child processes
214 are gone. Carrying a session across a reboot would need
215 checkpoint/restore, which handoff §6 explicitly defers as a different
216 problem (process location, not terminal rendering).
161 217
162 ## Open (owed by later milestones) 218 ## Open (owed by later milestones)
163 219
164 - Resize policy under multiple clients (M5) 220 - Scrollback retention *tuning*. The policy itself was decided in M1 and
165 - Scrollback retention/eviction limits (post-prototype) 221 is not open: retention is engine-native, a 10k-line ring
166 - Daemon lifetime across logout/reboot (M2) 222 (`Engine.Options.max_scrollback`), evicting oldest-first. What remains
223 deferred is making the limit configurable and choosing a non-default
224 value — nobody has hit the ceiling in practice, and M3 already accepted
225 that scroll positions drift as history evicts.
226
227 ## Prototype verdict (2026-08-07)
228
229 All five milestones are complete and all three kill criteria are
230 cleared. The two questions handoff §0 says this prototype exists to
231 answer are both answered yes.
232
233 - **M1 kill criterion — cleared.** The grid is extracted from upstream
234 ghostty-vt with no fork: an unmodified pinned dependency, driven
235 headlessly, serialized through its own formatters.
236 - **M3 kill criterion — cleared.** This is the one the log never recorded
237 a verdict for, so it is recorded here against a real run rather than an
238 impression. With `nvim` open full-screen in the session, the client was
239 killed with `kill -9`; the daemon survived, and a fresh client reattached
240 and painted the restored TUI screen — including buffer text typed before
241 the kill — **5 ms** from launch to first painted byte, from a single
242 snapshot. Quitting nvim then revealed the primary screen with the
243 pre-TUI shell line intact, exercising the M3 dual-screen snapshot.
244 Reattach is therefore both fast and correct for full-screen TUI
245 sessions, which is exactly what the criterion demanded. The e2e suite
246 pins the line-mode half of this (kill -9 mid-`seq`, then reattach);
247 the TUI half is demo-verified, not automated — see the banked list.
248 - **M4 kill criterion — cleared.** Delta traffic measured at 1% of the
249 snapshot equivalent on the typing workload (7751 vs 689537 bytes), and
250 ~6% under adversarial full-screen scrolling where every row changes.
251 - **Question 1 — can libghostty serve as an authoritative, headless,
252 serializable grid in a daemon? Yes.** Unforked, with the caveats
253 already logged: the API is documented-unstable so the pin is
254 load-bearing, and DECOM remains a known gap.
255 - **Question 2 — does detach/reattach as state sync feel correct and
256 fast? Yes.** Reattach is a single snapshot the client rebuilds
257 natively rather than a replay of session history, it restores TUI and
258 line-mode sessions alike, and under deltas the steady-state wire cost
259 is 1% of snapshotting.
260
261 Everything downstream of these answers — network transport, mesh,
262 multiplayer, panes, checkpoint/restore — remains deliberately out of
263 scope, and the known gaps owed before any of it are recorded above (no
264 session epoch, blocking per-client writes, hand-rolled wire format).
docs/superpowers/plans/2026-08-07-m5-two-clients.md
Old New
@@ -40,7 +40,7 @@ README.md — MODIFY: status
40 40
41 **Files:** Modify `src/protocol.zig`, then mechanical prefix-width migration in `src/server.zig` and `src/client.zig` so the build stays green within the task. 41 **Files:** Modify `src/protocol.zig`, then mechanical prefix-width migration in `src/server.zig` and `src/client.zig` so the build stays green within the task.
42 42
43 - [ ] **Step 1: Failing tests (append to src/protocol.zig)** 43 - [x] **Step 1: Failing tests (append to src/protocol.zig)**
44 44
45 ```zig 45 ```zig
46 test "snapshot prefix round trip and golden bytes" { 46 test "snapshot prefix round trip and golden bytes" {
@@ -62,9 +62,9 @@ test "snapshot prefix rejects short payloads" {
62 } 62 }
63 ``` 63 ```
64 64
65 - [ ] **Step 2: `make test` → compile failure.** 65 - [x] **Step 2: `make test` → compile failure.**
66 66
67 - [ ] **Step 3: Implement in src/protocol.zig** (near the delta header helpers; update the `snapshot` MsgType comment to `// payload: SnapshotPrefix ++ full-state vt dump`): 67 - [x] **Step 3: Implement in src/protocol.zig** (near the delta header helpers; update the `snapshot` MsgType comment to `// payload: SnapshotPrefix ++ full-state vt dump`):
68 68
69 ```zig 69 ```zig
70 /// Fixed prefix of every snapshot payload. Carries the grid size because 70 /// Fixed prefix of every snapshot payload. Carries the grid size because
@@ -97,12 +97,12 @@ pub fn readSnapshotPrefix(payload: []const u8) !SnapshotPrefix {
97 } 97 }
98 ``` 98 ```
99 99
100 - [ ] **Step 4: Migrate both senders/readers in the same commit** (mechanical; behavior identical since cols/rows are simply now carried): 100 - [x] **Step 4: Migrate both senders/readers in the same commit** (mechanical; behavior identical since cols/rows are simply now carried):
101 - src/server.zig `resyncSnapshot`: build the payload as `snapshot_prefix_len + state.len`; fill via `proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{ .seq = self.tracker.seq, .history_rows = self.eng.historyRows(), .cols = self.colsNow(), .rows = self.rowsNow() });` then `@memcpy(payload[proto.snapshot_prefix_len..], state);` 101 - src/server.zig `resyncSnapshot`: build the payload as `snapshot_prefix_len + state.len`; fill via `proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{ .seq = self.tracker.seq, .history_rows = self.eng.historyRows(), .cols = self.colsNow(), .rows = self.rowsNow() });` then `@memcpy(payload[proto.snapshot_prefix_len..], state);`
102 - src/server.zig tests: every `payload[0..8]` seq read → `(try proto.readSnapshotPrefix(frame.payload)).seq`; every `payload[8..12]` history read → `.history_rows` of the same; every `payload[12..]` state slice (incl. `applyFrame`) → `payload[proto.snapshot_prefix_len..]`. 102 - src/server.zig tests: every `payload[0..8]` seq read → `(try proto.readSnapshotPrefix(frame.payload)).seq`; every `payload[8..12]` history read → `.history_rows` of the same; every `payload[12..]` state slice (incl. `applyFrame`) → `payload[proto.snapshot_prefix_len..]`.
103 - src/client.zig `.snapshot` arm: guard `payload.len < proto.snapshot_prefix_len`, parse with `readSnapshotPrefix`, use `.history_rows`, feed `payload[proto.snapshot_prefix_len..]`. (Daemon-size USE arrives in Task 3 — here just parse and keep behavior.) 103 - src/client.zig `.snapshot` arm: guard `payload.len < proto.snapshot_prefix_len`, parse with `readSnapshotPrefix`, use `.history_rows`, feed `payload[proto.snapshot_prefix_len..]`. (Daemon-size USE arrives in Task 3 — here just parse and keep behavior.)
104 104
105 - [ ] **Step 5: `make test && make e2e` green. Commit:** `git add -A && git commit -m "feat: snapshot prefix carries grid size (16-byte prefix)"` 105 - [x] **Step 5: `make test && make e2e` green. Commit:** `git add -A && git commit -m "feat: snapshot prefix carries grid size (16-byte prefix)"`
106 106
107 --- 107 ---
108 108
@@ -110,7 +110,7 @@ pub fn readSnapshotPrefix(payload: []const u8) !SnapshotPrefix {
110 110
111 **Files:** Modify `src/server.zig` only. Gate: `make test` green; `make e2e` must ALSO stay green (single-client flows unchanged from outside). 111 **Files:** Modify `src/server.zig` only. Gate: `make test` green; `make e2e` must ALSO stay green (single-client flows unchanged from outside).
112 112
113 - [ ] **Step 1: Failing tests (append to src/server.zig; copy the poll-with-deadline + serverThread patterns from existing tests)** 113 - [x] **Step 1: Failing tests (append to src/server.zig; copy the poll-with-deadline + serverThread patterns from existing tests)**
114 114
115 Test A — "Server: two clients converge on one session": 115 Test A — "Server: two clients converge on one session":
116 ```zig 116 ```zig
@@ -204,9 +204,9 @@ Test B — "Server: latest attacher's size wins; earlier client is resnapshotted
204 204
205 Test C — "Server: scrollback fetch is per-client and independent": A and B attach 80x24; input `seq 1 100\n` via B; both drain until a frame reports history_rows >= 50 (delta headers or snapshot prefixes). A sends `fetch_scrollback` for rows 0..24 and must get a `scrollback_chunk` containing "seq 1 100"; B concurrently keeps receiving deltas (assert B's next state frame is NOT disturbed — just drain one frame successfully). This pins per-connection scrollback while another client streams. 205 Test C — "Server: scrollback fetch is per-client and independent": A and B attach 80x24; input `seq 1 100\n` via B; both drain until a frame reports history_rows >= 50 (delta headers or snapshot prefixes). A sends `fetch_scrollback` for rows 0..24 and must get a `scrollback_chunk` containing "seq 1 100"; B concurrently keeps receiving deltas (assert B's next state frame is NOT disturbed — just drain one frame successfully). This pins per-connection scrollback while another client streams.
206 206
207 - [ ] **Step 2: `make test` → failures/compile errors.** 207 - [x] **Step 2: `make test` → failures/compile errors.**
208 208
209 - [ ] **Step 3: Implement multi-client in src/server.zig:** 209 - [x] **Step 3: Implement multi-client in src/server.zig:**
210 210
211 ```zig 211 ```zig
212 const max_clients = 8; 212 const max_clients = 8;
@@ -224,7 +224,7 @@ Replace `client: ?std.posix.fd_t = null` with `clients: [max_clients]?std.posix.
224 - `.taken_over` is no longer sent by anyone; leave the MsgType and the client handler in place (comment in protocol.zig: retired in M5, kept for wire-compat). 224 - `.taken_over` is no longer sent by anyone; leave the MsgType and the client handler in place (comment in protocol.zig: retired in M5, kept for wire-compat).
225 - `replyStats`/debug_dump/fetch_scrollback arms work per-fd already — ensure the client-arm versions use the indexed fd and drop the right slot on failure. 225 - `replyStats`/debug_dump/fetch_scrollback arms work per-fd already — ensure the client-arm versions use the indexed fd and drop the right slot on failure.
226 226
227 - [ ] **Step 4: `make test` green AND `make e2e` green (single-client behavior must be externally unchanged). Commit:** `git commit -am "feat: multi-client broadcast with latest-wins resize; takeover retired"` 227 - [x] **Step 4: `make test` green AND `make e2e` green (single-client behavior must be externally unchanged). Commit:** `git commit -am "feat: multi-client broadcast with latest-wins resize; takeover retired"`
228 228
229 --- 229 ---
230 230
@@ -232,7 +232,7 @@ Replace `client: ?std.posix.fd_t = null` with `clients: [max_clients]?std.posix.
232 232
233 **Files:** Modify `src/client.zig` only. Gate: `make test && make e2e && make bench` all green. 233 **Files:** Modify `src/client.zig` only. Gate: `make test && make e2e && make bench` all green.
234 234
235 - [ ] **Step 1: Failing tests (append to src/client.zig)** 235 - [x] **Step 1: Failing tests (append to src/client.zig)**
236 236
237 ```zig 237 ```zig
238 test "renderClipped paints only rows that fit and clamps the cursor" { 238 test "renderClipped paints only rows that fit and clamps the cursor" {
@@ -295,9 +295,9 @@ test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
295 } 295 }
296 ``` 296 ```
297 297
298 - [ ] **Step 2: `make test` → compile failure.** 298 - [x] **Step 2: `make test` → compile failure.**
299 299
300 - [ ] **Step 3: Implement in src/client.zig:** 300 - [x] **Step 3: Implement in src/client.zig:**
301 301
302 New helpers (place near the old `render`/`paintDelta`, which they replace — delete the old ones and their tests EXCEPT keep `renderScrollback` untouched): 302 New helpers (place near the old `render`/`paintDelta`, which they replace — delete the old ones and their tests EXCEPT keep `renderScrollback` untouched):
303 303
@@ -368,7 +368,7 @@ Attach-loop changes:
368 - Scroll-exit paths and any other `render(...)` call sites become `renderClipped(alloc, replica, size, stdout_fd)`. 368 - Scroll-exit paths and any other `render(...)` call sites become `renderClipped(alloc, replica, size, stdout_fd)`.
369 - Audit: `render` and `paintDelta` (and their tests) deleted; no other references remain. 369 - Audit: `render` and `paintDelta` (and their tests) deleted; no other references remain.
370 370
371 - [ ] **Step 4: `make test && make e2e && make bench` all green. Commit:** `git commit -am "feat: client renders the authoritative grid clipped to its tty"` 371 - [x] **Step 4: `make test && make e2e && make bench` all green. Commit:** `git commit -am "feat: client renders the authoritative grid clipped to its tty"`
372 372
373 --- 373 ---
374 374
@@ -376,7 +376,7 @@ Attach-loop changes:
376 376
377 **Files:** Modify `test/e2e.sh`, `docs/decisions.md`, `README.md`, plan checkboxes. 377 **Files:** Modify `test/e2e.sh`, `docs/decisions.md`, `README.md`, plan checkboxes.
378 378
379 - [ ] **Step 1: Append to test/e2e.sh** (before the final `echo "e2e OK"`; add `"$OUT.a" "$OUT.b"` to cleanup): 379 - [x] **Step 1: Append to test/e2e.sh** (before the final `echo "e2e OK"`; add `"$OUT.a" "$OUT.b"` to cleanup):
380 380
381 ```sh 381 ```sh
382 # --- M5: two clients on one session, both converge; detach is independent. 382 # --- M5: two clients on one session, both converge; detach is independent.
@@ -393,11 +393,11 @@ kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1
393 rm -f "$OUT.a" "$OUT.b" 393 rm -f "$OUT.a" "$OUT.b"
394 ``` 394 ```
395 395
396 - [ ] **Step 2: `make test && make e2e && make bench` all green.** 396 - [x] **Step 2: `make test && make e2e && make bench` all green.**
397 397
398 - [ ] **Step 3: Scripted demo** (report transcript): daemon + two `mux` clients under `script` ptys of DIFFERENT sizes (e.g. 100x30 and 80x24) via fifos; type in one → text appears in both typescripts; grid follows the later attacher (verify `muxd dump` line width); Shift+PageUp in one client while typing in the other → the scrolled client's typescript shows the `[scroll]` marker page while the other keeps streaming (independence, the handoff's demo). 398 - [x] **Step 3: Scripted demo** (report transcript): daemon + two `mux` clients under `script` ptys of DIFFERENT sizes (e.g. 100x30 and 80x24) via fifos; type in one → text appears in both typescripts; grid follows the later attacher (verify `muxd dump` line width); Shift+PageUp in one client while typing in the other → the scrolled client's typescript shows the `[scroll]` marker page while the other keeps streaming (independence, the handoff's demo).
399 399
400 - [ ] **Step 4: docs/decisions.md — append (THE MANDATED RECORD):** 400 - [x] **Step 4: docs/decisions.md — append (THE MANDATED RECORD):**
401 401
402 ```markdown 402 ```markdown
403 ## 2026-08-07 (M5) 403 ## 2026-08-07 (M5)
@@ -429,9 +429,9 @@ rm -f "$OUT.a" "$OUT.b"
429 429
430 Also update the "## Open" section: remove "Resize policy under multiple clients (M5)" and "Daemon lifetime across logout/reboot (M2)" if still listed (the latter was answered by socket activation + lingering note in M2 — if it's still listed, resolve it with a pointer to contrib/). 430 Also update the "## Open" section: remove "Resize policy under multiple clients (M5)" and "Daemon lifetime across logout/reboot (M2)" if still listed (the latter was answered by socket activation + lingering note in M2 — if it's still listed, resolve it with a pointer to contrib/).
431 431
432 - [ ] **Step 5: README.md** — status `Status: **M5 — two clients.** All handoff milestones complete; all three kill criteria cleared.` and add a line under the usage block: `Multiple mux clients may attach to one session; the grid follows the most recent attacher/resize (latest wins).` 432 - [x] **Step 5: README.md** — status `Status: **M5 — two clients.** All handoff milestones complete; all three kill criteria cleared.` and add a line under the usage block: `Multiple mux clients may attach to one session; the grid follows the most recent attacher/resize (latest wins).`
433 433
434 - [ ] **Step 6: Flip all M5 plan checkboxes; commit:** `git commit -am "feat: two-client e2e; docs: resize decision recorded — M5 complete"` 434 - [x] **Step 6: Flip all M5 plan checkboxes; commit:** `git commit -am "feat: two-client e2e; docs: resize decision recorded — M5 complete"`
435 435
436 --- 436 ---
437 437
src/client.zig
Old New
@@ -119,6 +119,9 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
119 switch (frame.type) { 119 switch (frame.type) {
120 .snapshot => { 120 .snapshot => {
121 const prefix = proto.readSnapshotPrefix(frame.payload) catch continue; 121 const prefix = proto.readSnapshotPrefix(frame.payload) catch continue;
122 // Set after the prefix parse here but before the compose
123 // catch in the delta arm: a short snapshot proves nothing,
124 // while a delta's arrival alone proves we were admitted.
122 got_state = true; 125 got_state = true;
123 history_rows = prefix.history_rows; 126 history_rows = prefix.history_rows;
124 if (prefix.cols != grid.cols or prefix.rows != grid.rows) { 127 if (prefix.cols != grid.cols or prefix.rows != grid.rows) {
@@ -136,7 +139,9 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
136 const composed = proto.composeDelta(alloc, frame.payload) catch { 139 const composed = proto.composeDelta(alloc, frame.payload) catch {
137 // A rejected delta means the replica can no longer be 140 // A rejected delta means the replica can no longer be
138 // trusted; ask for a fresh snapshot rather than 141 // trusted; ask for a fresh snapshot rather than
139 // silently skipping it and desyncing for good. 142 // silently skipping it and desyncing for good. By
143 // latest-wins this re-attach also re-asserts our size
144 // onto the shared session — accepted.
140 proto.writeFrame(sock, .attach, &proto.encodeAttach(size.cols, size.rows, 0)) catch {}; 145 proto.writeFrame(sock, .attach, &proto.encodeAttach(size.cols, size.rows, 0)) catch {};
141 continue; 146 continue;
142 }; 147 };
@@ -152,11 +157,11 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
152 try renderScrollback(alloc, frame.payload[6..], size, stdout_fd); 157 try renderScrollback(alloc, frame.payload[6..], size, stdout_fd);
153 }, 158 },
154 .exit_status => { 159 .exit_status => {
155 // Before any session state, exit_status is the daemon 160 // Before any session state, exit_status is almost always
156 // refusing the attach — say so, or it looks exactly 161 // the daemon refusing the attach — say so, or it looks
157 // like the shell itself exiting non-zero. 162 // exactly like the shell itself exiting non-zero.
158 if (!got_state) { 163 if (!got_state) {
159 exit_msg = "mux: attach refused (session full)"; 164 exit_msg = "mux: attach refused or no state received (session full?)";
160 return 1; 165 return 1;
161 } 166 }
162 return if (frame.payload.len >= 1) frame.payload[0] else 0; 167 return if (frame.payload.len >= 1) frame.payload[0] else 0;
test/e2e.sh
Old New
@@ -7,7 +7,7 @@ MUX="$2"
7 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 7 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
8 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 8 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
9 9
10 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT" "$OUT.kill" "$OUT.re"; } 10 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.b"; }
11 trap cleanup EXIT INT TERM 11 trap cleanup EXIT INT TERM
12 12
13 "$MUXD" run --sock "$SOCK" --shell /bin/sh & 13 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
@@ -48,4 +48,25 @@ grep -q "60" "$OUT.re" || {
48 } 48 }
49 rm -f "$OUT.kill" "$OUT.re" 49 rm -f "$OUT.kill" "$OUT.re"
50 50
51 # --- M5: two clients on one session. Output typed in A reaches both; then A
52 # detaches and B must still have a live input path (its own marker echoes back
53 # after A is gone), so one client's detach does not disturb the other.
54 { sleep 0.5; printf 'printf "m5-%%s\\n" both\n'; sleep 2.5; printf '\034'; } | \
55 "$MUX" --sock "$SOCK" > "$OUT.a" &
56 APID=$!
57 { sleep 4; printf 'printf "m5-%%s\\n" after-a-left\n'; sleep 2.5; printf '\034'; } | \
58 "$MUX" --sock "$SOCK" > "$OUT.b" &
59 BPID=$!
60 wait "$APID" "$BPID"
61
62 # The marker text never appears in the echoed command line ("m5-%s" plus a
63 # separate argument), so a hit proves the shell ran it and the result came back.
64 grep -q "m5-both" "$OUT.a" || { echo "e2e FAIL: client A missing shared output"; exit 1; }
65 grep -q "m5-both" "$OUT.b" || { echo "e2e FAIL: client B missing shared output"; exit 1; }
66 grep -q "m5-after-a-left" "$OUT.b" || {
67 echo "e2e FAIL: client B lost its input path after A detached"; exit 1;
68 }
69 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; }
70 rm -f "$OUT.a" "$OUT.b"
71
51 echo "e2e OK" 72 echo "e2e OK"