a73x

9ea510f6

test: kill-reattach e2e; docs: M3 decisions and usage

a73x   2026-08-08 14:08

Commit message
test: kill-reattach e2e; docs: M3 decisions and usage

README.md
Old New
@@ -5,7 +5,7 @@ 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: **M2 — the loop.** 8 Status: **M3 — the promise.**
9 9
10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned 10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned
11 toolchain, override with `make ZIG=...`. 11 toolchain, override with `make ZIG=...`.
@@ -13,9 +13,11 @@ toolchain, override with `make ZIG=...`.
13 make test && make e2e # verify 13 make test && make e2e # verify
14 make build 14 make build
15 ./zig-out/bin/muxd run & # daemon 15 ./zig-out/bin/muxd run & # daemon
16 ./zig-out/bin/mux # attach a client (Ctrl-\ detaches) 16 ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll)
17 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid 17 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid
18 18
19 Detaching (`Ctrl-\`) leaves the session running; re-running `mux` resumes 19 Detach, or kill the client outright — the session survives and `mux`
20 it from a state snapshot. systemd user units (socket activation) live in 20 resumes it from a state snapshot, including TUI screens and the primary
21 `contrib/`. 21 screen behind them. Scrollback is fetched lazily (Shift+PageUp/PageDown
22 pages; any other key returns to live). systemd user units (socket
23 activation) live in `contrib/`.
docs/decisions.md
Old New
@@ -73,6 +73,27 @@
73 - **systemd socket activation supported** via LISTEN_PID/LISTEN_FDS (fd 3); 73 - **systemd socket activation supported** via LISTEN_PID/LISTEN_FDS (fd 3);
74 units in contrib/. Verified with systemd-socket-activate. 74 units in contrib/. Verified with systemd-socket-activate.
75 75
76 ## 2026-08-07 (M3)
77
78 - **Scrollback addressing: screen-space row index** (0 = oldest retained
79 row). Positions drift as history evicts at max_scrollback; scroll
80 positions are ephemeral, so drift is acceptable. Fetch is pull-only:
81 snapshots carry a u32 history_rows count, never history content.
82 - **Scroll UX: Shift+PageUp/PageDown** (`\x1b[5;2~`/`\x1b[6;2~`), page at a
83 time, remote-paged with no client cache (a page fetch is ~2KB over a
84 local socket). Any other key snaps back to live and is swallowed. Live
85 snapshots keep applying while scrolled but paint only on return.
86 - **Dual-screen snapshots.** When the alt screen is active, dumpState
87 prepends the primary screen's visible content before the full-state dump
88 (whose mode section performs the alt switch). Primary saved-cursor lands
89 at end-of-content, not the exact pre-TUI position — accepted.
90 - **SIGPIPE ignored in both processes.** A kill -9'd client previously
91 killed the daemon via SIGPIPE on the next snapshot write — found by the
92 M3 kill-reattach scenario, now covered by a server test and e2e.
93 - **SGR canonicalization note:** the formatter emits palette colors in
94 indexed form (31 -> 38;5;1). Semantically identical; tests assert the
95 canonical form.
96
76 ## Open (owed by later milestones) 97 ## Open (owed by later milestones)
77 98
78 - Resize policy under multiple clients (M5) 99 - Resize policy under multiple clients (M5)
docs/superpowers/plans/2026-08-07-m3-the-promise.md
Old New
@@ -42,7 +42,7 @@ Decisions locked in (recorded in decisions.md, Task 6): scrollback addressing is
42 **Files:** 42 **Files:**
43 - Modify: `src/protocol.zig` 43 - Modify: `src/protocol.zig`
44 44
45 - [ ] **Step 1: Add failing tests (append to `src/protocol.zig`)** 45 - [x] **Step 1: Add failing tests (append to `src/protocol.zig`)**
46 46
47 ```zig 47 ```zig
48 test "u32 encode/decode round trip" { 48 test "u32 encode/decode round trip" {
@@ -58,12 +58,12 @@ test "scrollback request encode/decode round trip" {
58 } 58 }
59 ``` 59 ```
60 60
61 - [ ] **Step 2: Run to verify failure** 61 - [x] **Step 2: Run to verify failure**
62 62
63 Run: `make test` 63 Run: `make test`
64 Expected: compile error — `putU32` etc. undefined. 64 Expected: compile error — `putU32` etc. undefined.
65 65
66 - [ ] **Step 3: Implement (add to `src/protocol.zig`), and update the MsgType docs** 66 - [x] **Step 3: Implement (add to `src/protocol.zig`), and update the MsgType docs**
67 67
68 Replace the `MsgType` payload comments for `snapshot` and add the two new types: 68 Replace the `MsgType` payload comments for `snapshot` and add the two new types:
69 69
@@ -108,9 +108,9 @@ pub fn decodeScrollbackReq(payload: []const u8) !ScrollbackReq {
108 } 108 }
109 ``` 109 ```
110 110
111 - [ ] **Step 4: Run tests** — `make test`, expected exit 0. 111 - [x] **Step 4: Run tests** — `make test`, expected exit 0.
112 112
113 - [ ] **Step 5: Commit** 113 - [x] **Step 5: Commit**
114 114
115 ```bash 115 ```bash
116 git add src/protocol.zig 116 git add src/protocol.zig
@@ -124,7 +124,7 @@ git commit -m "feat: scrollback protocol messages and integer helpers"
124 **Files:** 124 **Files:**
125 - Modify: `src/engine.zig` 125 - Modify: `src/engine.zig`
126 126
127 - [ ] **Step 1: Add failing tests (append to `src/engine.zig`)** 127 - [x] **Step 1: Add failing tests (append to `src/engine.zig`)**
128 128
129 ```zig 129 ```zig
130 test "Engine: historyRows counts scrolled-off lines, zero on alt screen" { 130 test "Engine: historyRows counts scrolled-off lines, zero on alt screen" {
@@ -175,9 +175,9 @@ test "Engine: dumpScrollback serves styled history rows by screen-space range" {
175 } 175 }
176 ``` 176 ```
177 177
178 - [ ] **Step 2: Run to verify failure** — `make test`, expected: compile error. 178 - [x] **Step 2: Run to verify failure** — `make test`, expected: compile error.
179 179
180 - [ ] **Step 3: Implement (add to `Engine`)** 180 - [x] **Step 3: Implement (add to `Engine`)**
181 181
182 ```zig 182 ```zig
183 /// Number of history (scrolled-off) rows above the viewport on the 183 /// Number of history (scrolled-off) rows above the viewport on the
@@ -218,9 +218,9 @@ test "Engine: dumpScrollback serves styled history rows by screen-space range" {
218 218
219 Type note: `pt.screen.y` is a coordinate integer; `total -| 1` guards the empty-terminal case. `self.term.rows` is `size.CellCountInt` — widen with `@as(u32, self.term.rows)` if the compiler complains about the addition. 219 Type note: `pt.screen.y` is a coordinate integer; `total -| 1` guards the empty-terminal case. `self.term.rows` is `size.CellCountInt` — widen with `@as(u32, self.term.rows)` if the compiler complains about the addition.
220 220
221 - [ ] **Step 4: Run tests** — `make test`, expected exit 0. If the `historyRows` expectation of 77 is off by one (prompt-row accounting), print the actual value, verify it equals `lines_fed + 1 - 24` reasoning, and adjust the *comment and constant together*. 221 - [x] **Step 4: Run tests** — `make test`, expected exit 0. If the `historyRows` expectation of 77 is off by one (prompt-row accounting), print the actual value, verify it equals `lines_fed + 1 - 24` reasoning, and adjust the *comment and constant together*.
222 222
223 - [ ] **Step 5: Commit** 223 - [x] **Step 5: Commit**
224 224
225 ```bash 225 ```bash
226 git add src/engine.zig 226 git add src/engine.zig
@@ -234,7 +234,7 @@ git commit -m "feat: engine history introspection and styled scrollback dumps"
234 **Files:** 234 **Files:**
235 - Modify: `src/engine.zig` (dumpState + the alt-screen test) 235 - Modify: `src/engine.zig` (dumpState + the alt-screen test)
236 236
237 - [ ] **Step 1: Un-weaken the alt-screen test** 237 - [x] **Step 1: Un-weaken the alt-screen test**
238 238
239 In `test "Engine: alt-screen state survives snapshot into fresh engine"`, replace the final block: 239 In `test "Engine: alt-screen state survives snapshot into fresh engine"`, replace the final block:
240 240
@@ -248,9 +248,9 @@ In `test "Engine: alt-screen state survives snapshot into fresh engine"`, replac
248 } 248 }
249 ``` 249 ```
250 250
251 - [ ] **Step 2: Run to verify failure** — `make test`, expected: that assertion fails. 251 - [x] **Step 2: Run to verify failure** — `make test`, expected: that assertion fails.
252 252
253 - [ ] **Step 3: Implement dual-screen `dumpState`** 253 - [x] **Step 3: Implement dual-screen `dumpState`**
254 254
255 Replace `dumpState` with: 255 Replace `dumpState` with:
256 256
@@ -303,9 +303,9 @@ API note: `ScreenFormatter.init(screen: *Screen, opts)` and its `extra: ScreenFo
303 303
304 Ordering subtlety that makes this correct: the TerminalFormatter's *modes* section (which contains the switch-to-alt-screen DECSET) is emitted **before** its screen-content section, so the replica is still on the primary screen while the prepended primary content applies, and already on the alt screen when the alt content applies. 304 Ordering subtlety that makes this correct: the TerminalFormatter's *modes* section (which contains the switch-to-alt-screen DECSET) is emitted **before** its screen-content section, so the replica is still on the primary screen while the prepended primary content applies, and already on the alt screen when the alt content applies.
305 305
306 - [ ] **Step 4: Run tests** — `make test`, expected exit 0, including the strengthened alt test and the M2 fidelity test (which round-trips dumpState through a live daemon). 306 - [x] **Step 4: Run tests** — `make test`, expected exit 0, including the strengthened alt test and the M2 fidelity test (which round-trips dumpState through a live daemon).
307 307
308 - [ ] **Step 5: Commit** 308 - [x] **Step 5: Commit**
309 309
310 ```bash 310 ```bash
311 git add src/engine.zig 311 git add src/engine.zig
@@ -319,7 +319,7 @@ git commit -m "feat: dual-screen snapshots — primary content survives alt-scre
319 **Files:** 319 **Files:**
320 - Modify: `src/server.zig` 320 - Modify: `src/server.zig`
321 321
322 - [ ] **Step 1: Add failing tests (append to `src/server.zig`)** 322 - [x] **Step 1: Add failing tests (append to `src/server.zig`)**
323 323
324 ```zig 324 ```zig
325 test "Server: survives a client that dies without detaching; next attach works" { 325 test "Server: survives a client that dies without detaching; next attach works" {
@@ -437,9 +437,9 @@ test "Server: serves scrollback chunks on request" {
437 } 437 }
438 ``` 438 ```
439 439
440 - [ ] **Step 2: Run to verify failure** — `make test`. Expected: first test may PASS already on platforms where the write happens after poll reveals POLLHUP (drop-on-read-EOF path) — that's fine, it pins the behavior; the scrollback test must FAIL (no fetch handler). 440 - [x] **Step 2: Run to verify failure** — `make test`. Expected: first test may PASS already on platforms where the write happens after poll reveals POLLHUP (drop-on-read-EOF path) — that's fine, it pins the behavior; the scrollback test must FAIL (no fetch handler).
441 441
442 - [ ] **Step 3: Implement** 442 - [x] **Step 3: Implement**
443 443
444 In `installSignalHandlers`, add SIGPIPE ignore: 444 In `installSignalHandlers`, add SIGPIPE ignore:
445 445
@@ -482,9 +482,9 @@ In `serviceClient`'s switch, add:
482 }, 482 },
483 ``` 483 ```
484 484
485 - [ ] **Step 4: Run tests** — `make test`, expected exit 0. The M2 fidelity test still passes because the replica-feed in that test must be updated to skip the 4-byte prefix — do that now: in `test "Server: replica rebuilt from snapshots matches the authoritative grid"`, change both `replica.feed(frame.payload);` occurrences to `replica.feed(frame.payload[4..]);`. 485 - [x] **Step 4: Run tests** — `make test`, expected exit 0. The M2 fidelity test still passes because the replica-feed in that test must be updated to skip the 4-byte prefix — do that now: in `test "Server: replica rebuilt from snapshots matches the authoritative grid"`, change both `replica.feed(frame.payload);` occurrences to `replica.feed(frame.payload[4..]);`.
486 486
487 - [ ] **Step 5: Commit** 487 - [x] **Step 5: Commit**
488 488
489 ```bash 489 ```bash
490 git add src/server.zig 490 git add src/server.zig
@@ -498,7 +498,7 @@ git commit -m "feat: daemon serves scrollback; snapshot carries history count; k
498 **Files:** 498 **Files:**
499 - Modify: `src/client.zig` 499 - Modify: `src/client.zig`
500 500
501 - [ ] **Step 1: Implement (no isolated unit test — the moving parts are fd-loop glue; coverage comes from Task 4's protocol tests plus Task 6's e2e; the marker rendering gets a small test below)** 501 - [x] **Step 1: Implement (no isolated unit test — the moving parts are fd-loop glue; coverage comes from Task 4's protocol tests plus Task 6's e2e; the marker rendering gets a small test below)**
502 502
503 At the top of `attach`, after the socket connects, ignore SIGPIPE (a daemon that dies mid-write must surface as an error return, not kill us): 503 At the top of `attach`, after the socket connects, ignore SIGPIPE (a daemon that dies mid-write must surface as an error return, not kill us):
504 504
@@ -635,9 +635,9 @@ test "renderScrollback paints rows with an inverse scroll marker" {
635 635
636 Note the `.snapshot` arm no longer calls `replica.reset()+feed` unconditionally — while scrolled, the latest state is stored and painted on scroll-exit, so the user never loses live output, it's just deferred. 636 Note the `.snapshot` arm no longer calls `replica.reset()+feed` unconditionally — while scrolled, the latest state is stored and painted on scroll-exit, so the user never loses live output, it's just deferred.
637 637
638 - [ ] **Step 2: Run tests + build** — `make test && make build`, expected exit 0. 638 - [x] **Step 2: Run tests + build** — `make test && make build`, expected exit 0.
639 639
640 - [ ] **Step 3: Commit** 640 - [x] **Step 3: Commit**
641 641
642 ```bash 642 ```bash
643 git add src/client.zig 643 git add src/client.zig
@@ -651,7 +651,7 @@ git commit -m "feat: client scroll mode with lazy history paging; SIGPIPE-safe"
651 **Files:** 651 **Files:**
652 - Modify: `test/e2e.sh`, `docs/decisions.md`, `README.md` 652 - Modify: `test/e2e.sh`, `docs/decisions.md`, `README.md`
653 653
654 - [ ] **Step 1: Append the kill-reattach scenario to `test/e2e.sh`** (before the final `echo "e2e OK"`) 654 - [x] **Step 1: Append the kill-reattach scenario to `test/e2e.sh`** (before the final `echo "e2e OK"`)
655 655
656 ```sh 656 ```sh
657 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly. 657 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
@@ -671,16 +671,16 @@ rm -f "$OUT.kill" "$OUT.re"
671 671
672 Also add `"$OUT.kill" "$OUT.re"` to the `cleanup()` rm line. 672 Also add `"$OUT.kill" "$OUT.re"` to the `cleanup()` rm line.
673 673
674 - [ ] **Step 2: Run** — `make e2e`, expected `e2e OK`. 674 - [x] **Step 2: Run** — `make e2e`, expected `e2e OK`.
675 675
676 - [ ] **Step 3: Manual demo (M3 acceptance)** — scripted equivalents acceptable, real-terminal run preferred: 676 - [x] **Step 3: Manual demo (M3 acceptance)** — scripted equivalents acceptable, real-terminal run preferred:
677 677
678 1. `muxd run &`, `mux`, run `seq 1 200`, **Shift+PageUp** — oldest lines appear with an inverse `[scroll]` marker; Shift+PageUp/PageDown page through; any key returns live. 678 1. `muxd run &`, `mux`, run `seq 1 200`, **Shift+PageUp** — oldest lines appear with an inverse `[scroll]` marker; Shift+PageUp/PageDown page through; any key returns live.
679 2. Run `while true; do date; sleep 1; done`, `kill -9` the mux process from another terminal, wait a few seconds, re-run `mux`: the counter continued (timestamps advanced while detached) and the screen is current. 679 2. Run `while true; do date; sleep 1; done`, `kill -9` the mux process from another terminal, wait a few seconds, re-run `mux`: the counter continued (timestamps advanced while detached) and the screen is current.
680 3. Open `nvim`, kill the client, reattach: nvim's screen is correct. Quit nvim (`:q`): the shell history is there, not a blank screen. 680 3. Open `nvim`, kill the client, reattach: nvim's screen is correct. Quit nvim (`:q`): the shell history is there, not a blank screen.
681 4. Scrollback after reattach: the history from before the detach pages correctly. 681 4. Scrollback after reattach: the history from before the detach pages correctly.
682 682
683 - [ ] **Step 4: Update `docs/decisions.md`** — append: 683 - [x] **Step 4: Update `docs/decisions.md`** — append:
684 684
685 ```markdown 685 ```markdown
686 ## 2026-08-07 (M3) 686 ## 2026-08-07 (M3)
@@ -702,7 +702,7 @@ Also add `"$OUT.kill" "$OUT.re"` to the `cleanup()` rm line.
702 M3 kill-reattach scenario, now covered by a server test and e2e. 702 M3 kill-reattach scenario, now covered by a server test and e2e.
703 ``` 703 ```
704 704
705 - [ ] **Step 5: Update `README.md`** — replace the status line and usage block: 705 - [x] **Step 5: Update `README.md`** — replace the status line and usage block:
706 706
707 ```markdown 707 ```markdown
708 Status: **M3 — the promise.** 708 Status: **M3 — the promise.**
@@ -718,7 +718,7 @@ resumes it from a state snapshot, including scrollback (fetched lazily)
718 and TUI screens. 718 and TUI screens.
719 ``` 719 ```
720 720
721 - [ ] **Step 6: Commit** 721 - [x] **Step 6: Commit**
722 722
723 ```bash 723 ```bash
724 git add test/e2e.sh docs/decisions.md README.md 724 git add test/e2e.sh docs/decisions.md README.md
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"; } 10 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT" "$OUT.kill" "$OUT.re"; }
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 &
@@ -34,4 +34,18 @@ grep -q "e2e-works" "$OUT" || {
34 # 3. Detach left the daemon running. 34 # 3. Detach left the daemon running.
35 kill -0 "$DPID" || { echo "e2e FAIL: daemon died on detach"; exit 1; } 35 kill -0 "$DPID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
36 36
37 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
38 { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" &
39 CPID=$!
40 sleep 1
41 kill -9 "$CPID" 2>/dev/null || true
42 sleep 1
43 kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; }
44
45 { sleep 1; printf '\034'; } | "$MUX" --sock "$SOCK" > "$OUT.re"
46 grep -q "60" "$OUT.re" || {
47 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
48 }
49 rm -f "$OUT.kill" "$OUT.re"
50
37 echo "e2e OK" 51 echo "e2e OK"