909a0377
feat: two-client e2e and demo; docs: resize decision recorded — M5 complete
a73x 2026-08-08 14:08
Commit message
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" |