a73x

88bcfd7a

test: e2e — the wall of hosts (two daemons, x two-step, restart re-creates nothing)

a73x   2026-08-28 19:53

Commit message
test: e2e — the wall of hosts (two daemons, x two-step, restart re-creates nothing)

Five scenarios, on TWO daemons throughout: the claim is that tiles come
from the daemons and not from a file, and one daemon cannot show which of
two a tile belongs to. Both of them hold a session called `0` for the
same reason — a wall keyed on the session name would paint three tiles
and call it four.

  * `mux --sock` records the DAEMON line and never a `#`, and a LISTED
    local daemon that is gone is started: the exec of that path had no
    test at all. Its witness is the shell's own `$$` off the grid and
    /proc's parent link, not the wall's word for it.
  * every live session of every listed daemon is a tile; `n` walks them
    (where the two markers LANDED, read off the daemons); and `Ctrl-\ c`
    births a session that the next `mux` finds, with the hosts file
    byte-identical across it.
  * `x` is refused by count while another client is attached — the shell
    survives, asked of the OS — then forced, and the other client comes
    back with the session's status.
  * a daemon that goes down is ONE `[unreachable]` stripe, not one dead
    tile per session it used to have; back up, it holds its own default
    session and nothing else. Nothing is re-created.
  * `hosts rm` takes a daemon off the wall and ends nothing.

`[no sessions]` is not asserted: a daemon exits when its last session
ends (`SessionTable.reap`), so a reachable host with an empty list is not
a state a local daemon can be in.

Replaces e2e_09_wallhist.sh, whose subject — the wall file as attach
history, `x` forgetting a tile, `Ctrl-\ w` folding a saved wall in — the
CLI no longer has.

test/e2e.sh
Old New
@@ -112,7 +112,7 @@ command -v python3 > /dev/null 2>&1 || {
112 E2E_DIR=$(dirname "$0") 112 E2E_DIR=$(dirname "$0")
113 . "$E2E_DIR/e2e_lib.sh" 113 . "$E2E_DIR/e2e_lib.sh"
114 114
115 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes 13_birth 14_upgrade' 115 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_hosts 10_agent 11_select 12_panes 13_birth 14_upgrade'
116 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses 116 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses
117 # these by name rather than running them: a group whose fixtures are 117 # these by name rather than running them: a group whose fixtures are
118 # missing does not fail, it passes having tested something else — and a 118 # missing does not fail, it passes having tested something else — and a
test/e2e_09_hosts.sh
Old New
@@ -0,0 +1,489 @@
1 # shellcheck shell=sh
2 # e2e_09_hosts.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may
4 # assume and what it must register.
5 #
6 # The wall is a list of DAEMONS: two of them here, because the claim is
7 # that the tiles come from the daemons and not from a file, and one daemon
8 # cannot show which of the two a tile belongs to. Both daemons have a
9 # session named `0` for exactly that reason — a wall that keyed tiles on
10 # the session name alone would show one of them twice.
11 #
12 # A state home of its own, and one per leg that reads the file back: every
13 # `mux` in this suite now records the DAEMON it attached to, so a hosts
14 # file read out of the shared $XDG_STATE_HOME would be every other group's
15 # attaches as much as this one's.
16 SOCKH1="${TMPDIR:-/tmp}/muxd-e2e-hosts1-$$.sock"
17 defer_sock "$SOCKH1"
18 SOCKH2="${TMPDIR:-/tmp}/muxd-e2e-hosts2-$$.sock"
19 defer_sock "$SOCKH2"
20 HSTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-state-$$"
21 defer_rm "$HSTATE"
22 # The auto-start leg: a state home whose hosts file NAMES the local daemon,
23 # and a runtime dir of its own so `--sock <default>` resolves to a path
24 # nothing in this suite — or on this developer's machine — is already
25 # listening on. Without the second, the leg would start (or worse, adopt)
26 # the daemon the user is sitting in.
27 HASTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-auto-$$"
28 defer_rm "$HASTATE"
29 HARUN="${TMPDIR:-/tmp}/mux-e2e-hosts-run-$$"
30 defer_rm "$HARUN"
31 HASOCK="$HARUN/muxd.sock"
32 defer_sock "$HASOCK"
33 # The `x` leg builds and eats its own daemon's session, so it reads a wall
34 # of ONE host and gets a state home nothing else writes.
35 HXSTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-x-state-$$"
36 defer_rm "$HXSTATE"
37
38 start_daemon "$SOCKH1" "$OUT.h1.d" "hosts daemon 1 never bound" --shell /bin/sh
39 DH1PID=$DPID
40 start_daemon "$SOCKH2" "$OUT.h2.d" "hosts daemon 2 never bound" --shell /bin/sh
41 DH2PID=$DPID
42
43 # hosts_where NEEDLE — which of the four live sessions on the two daemons
44 # hold NEEDLE, one `SOCK:NAME` per line. The wall's tiles are read back
45 # from the DAEMONS that own them, never from the terminal: a marker on
46 # screen says a stripe painted, and what these legs claim is which SESSION
47 # a keystroke reached.
48 hosts_where() {
49 for _hw in "$SOCKH1:0" "$SOCKH1:b" "$SOCKH2:0" "$SOCKH2:c"; do
50 if dump_session "${_hw%%:*}" "${_hw##*:}" 2>/dev/null | grep -q "$1"; then
51 printf '%s\n' "$_hw"
52 fi
53 done
54 }
55
56 # ---- the wall records DAEMONS, and starts a local one that is not there --
57 #
58 # Two halves of the same rule, and the file is the witness for both.
59 #
60 # * `mux --sock PATH` records the line `--sock PATH` — the DAEMON — once
61 # its dial has come up. Never `#0`: a wall line names nothing that can
62 # be resurrected, so a `#` anywhere in this file is the regression.
63 # * a line naming the LOCAL daemon is auto-started when nothing answers
64 # on it. That line outlives every reboot while the daemon does not, and
65 # a wall that paints the user's own machine `[unreachable]` until they
66 # find some other shell to start a daemon in is the empty-file case
67 # with one line in front of it.
68 #
69 # The auto-start half is asked of the OS, not of the wall: the marker is
70 # the SHELL's own `$$`, and `muxd stats` on the default path is a second
71 # process finding a daemon that was not there a moment ago.
72 pipe_mux "$OUT.hw1" "$OUT.hw1.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" --sock "$SOCKH1"
73 pipe_send 'printf "hw1-%%s\\n" pin\n'
74 await_out "$OUT.hw1" "hw1-pin" "hw1-pin never reached the client"
75 pipe_detach "hosts client 1"
76 wait_grid "$SOCKH1" "hw1-pin" "hosts: the default session's marker"
77 grep -qxF -- "--sock $SOCKH1" "$HSTATE/mux/hosts" || {
78 echo "e2e FAIL: hosts: the attach recorded no daemon line:"
79 cat "$HSTATE/mux/hosts" 2>&1; exit 1; }
80 if grep -q '#' "$HSTATE/mux/hosts"; then
81 echo "e2e FAIL: hosts: a session leaked into the hosts file:"
82 cat "$HSTATE/mux/hosts"; exit 1
83 fi
84
85 # The listed-local half. The file is written by hand rather than by an
86 # attach, because what is under test is a line whose daemon is GONE — the
87 # state every hosts file is in after a reboot.
88 mkdir -p "$HASTATE/mux" "$HARUN"
89 printf -- '--sock %s\n' "$HASOCK" > "$HASTATE/mux/hosts"
90 [ ! -S "$HASOCK" ] || { echo "e2e FAIL: hosts: the auto-start path is already served"; exit 1; }
91 set +e
92 # PATH so `findInPath` resolves exactly the muxd under test, and
93 # XDG_RUNTIME_DIR so `--sock <default>` is this leg's own path; the wall
94 # needs a terminal, so this is the pty fixture and not a pipe.
95 SHELL=/bin/sh PATH="$(dirname "$MUXD"):$PATH" XDG_RUNTIME_DIR="$HARUN" \
96 XDG_STATE_HOME="$HASTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \
97 --out "$OUT.hauto" --err "$OUT.hauto.err" -- "$MUX" > "$OUT.hauto.log" 2>&1 <<'EOF'
98 expect \x1b[?1049h 20000
99 settle 600 20000
100 send echo hapid=$$\n
101 expect hapid= 20000
102 settle 600 20000
103 send \x1cd
104 waitexit 10000
105 EOF
106 RC=$?
107 set -e
108 [ "$RC" -eq 0 ] || {
109 echo "e2e FAIL: hosts: the auto-start leg exited $RC (did the listed local daemon start?):"
110 cat "$OUT.hauto.log"; cat "$OUT.hauto.err" 2>/dev/null; exit 1; }
111 HAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.hauto.err" | head -1)
112 defer_kill "$HAPID"
113 [ -n "$HAPID" ] || {
114 echo "e2e FAIL: hosts: the auto-start printed no pid to hold onto:"
115 cat "$OUT.hauto.err"; exit 1; }
116 # The daemon exists and holds the shell that answered — asked of the daemon
117 # by a process that had nothing to do with starting it.
118 HASH_PID=$("$MUXD" dump --sock "$HASOCK" | sed -n 's/.*hapid=\([0-9]*\).*/\1/p' | tail -1)
119 [ -n "$HASH_PID" ] || {
120 echo "e2e FAIL: hosts: the auto-started daemon holds no shell pid:"
121 "$MUXD" dump --sock "$HASOCK"; exit 1; }
122 # ...and the pid on the grid is a process the OS agrees exists, in the
123 # daemon's own tree. A daemon reporting on itself cannot catch itself
124 # being wrong.
125 [ "$(ps -o ppid= -p "$HASH_PID" 2>/dev/null | tr -d ' ')" = "$(real_pid "$HAPID")" ] || {
126 echo "e2e FAIL: hosts: the shell on the grid is not the auto-started daemon's child"
127 ps -o pid,ppid,args -p "$HASH_PID" 2>&1; exit 1; }
128 assert_stopped "$HASOCK" "$HAPID" "hosts auto-start" "$OUT.hastop"
129 HAPID=""
130 ok "mux records the daemon on the wall, never a session, and starts a listed local one that is gone"
131
132 # ---- every live session of every listed daemon is a tile ----------------
133 #
134 # The headline. Two daemons on the wall, four sessions between them, and
135 # the wall is asked for all four AT ONCE — on a real pty, because a stripe
136 # is a rect on a terminal and a pipe has none.
137 #
138 # Three claims:
139 #
140 # * four bars, and each names the daemon it belongs to. Both daemons have
141 # a session called `0`; a wall keyed on the session name would paint
142 # three tiles and call it four.
143 # * `Ctrl-\ n` walks them. Where the two markers LANDED is read off the
144 # daemons, so a step that only moved a `>` on screen fails here.
145 # * `Ctrl-\ c` births a session, and the file does not move. The wall is
146 # the daemons' truth: a second `mux` finds the new session as a tile
147 # with nothing written down anywhere.
148 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts add "--sock $SOCKH2" > "$OUT.hadd" 2>&1 || {
149 echo "e2e FAIL: hosts: 'hosts add' refused the second daemon:"
150 cat "$OUT.hadd"; exit 1; }
151 pipe_mux "$OUT.hb" "$OUT.hb.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" --sock "$SOCKH1" --session b
152 pipe_send 'printf "hb-%%s\\n" pin\n'
153 await_out "$OUT.hb" "hb-pin" "hb-pin never reached the client"
154 pipe_detach "hosts session b"
155 wait_grid "$SOCKH1" "hb-pin" "hosts: session b's marker" b
156 pipe_mux "$OUT.hc" "$OUT.hc.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" --sock "$SOCKH2" --session c
157 pipe_send 'printf "hc-%%s\\n" pin\n'
158 await_out "$OUT.hc" "hc-pin" "hc-pin never reached the client"
159 pipe_detach "hosts session c"
160 wait_grid "$SOCKH2" "hc-pin" "hosts: session c's marker" c
161 wait_sessions "$SOCKH1" 2 "hosts: daemon 1 should hold 0 and b"
162 wait_sessions "$SOCKH2" 2 "hosts: daemon 2 should hold 0 and c"
163
164 # The file, byte for byte, across a `Ctrl-\ c` that creates a session: the
165 # wall's tiles come from the daemons, so a birth writes nothing down.
166 HFHASH=$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1)
167 set +e
168 # Tall and wide: five tiles (four sessions plus the one `Ctrl-\ c` makes)
169 # have to fit under the row floor a stripe's label bar leaves.
170 XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 44 \
171 --out "$OUT.hwcap" --err "$OUT.hwcap.err" -- "$MUX" > "$OUT.hwpc" 2>&1 <<'EOF'
172 expect hc-pin 25000
173 settle 1500 25000
174 send printf 'walk-%s\\n' one\n
175 expect walk-one 20000
176 settle 800 20000
177 send \x1cn
178 settle 800 20000
179 send printf 'walk-%s\\n' two\n
180 expect walk-two 20000
181 settle 800 20000
182 send \x1cc
183 settle 2500 25000
184 send \x1cd
185 waitexit 15000
186 EOF
187 RC=$?
188 set -e
189 [ "$RC" -eq 0 ] || {
190 echo "e2e FAIL: hosts: the wall leg exited $RC (did every daemon's sessions become tiles?):"
191 cat "$OUT.hwpc"; exit 1; }
192 # Four bars, each naming its own daemon.
193 for _hl in "$SOCKH1#0" "$SOCKH1#b" "$SOCKH2#0" "$SOCKH2#c"; do
194 grep -q -- "--sock $_hl \[up\]" "$OUT.hwcap" || {
195 echo "e2e FAIL: hosts: '--sock $_hl' never became a stripe on the wall:"
196 cat "$OUT.hwpc"; exit 1; }
197 done
198 # The walk, read off the daemons. One holder each, and not the same one:
199 # `n` moved the focus to a different SESSION, on whichever daemon owns it.
200 HW1=$(hosts_where walk-one)
201 HW2=$(hosts_where walk-two)
202 [ "$(printf '%s\n' "$HW1" | grep -c .)" = "1" ] || {
203 echo "e2e FAIL: hosts: 'walk-one' landed in [$HW1], want exactly one session"; exit 1; }
204 [ "$(printf '%s\n' "$HW2" | grep -c .)" = "1" ] || {
205 echo "e2e FAIL: hosts: 'walk-two' landed in [$HW2], want exactly one session"; exit 1; }
206 [ "$HW1" != "$HW2" ] || {
207 echo "e2e FAIL: hosts: Ctrl-\\ n did not move the focus — both markers landed in $HW1"; exit 1; }
208 # The birth wrote nothing down, and the daemon really has one more session.
209 [ "$HFHASH" = "$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1)" ] || {
210 echo "e2e FAIL: hosts: Ctrl-\\ c edited the hosts file — a birth is the daemon's, not the file's:"
211 cat "$HSTATE/mux/hosts"; exit 1; }
212 HBORN=$(( $("$MUXD" stats --sock "$SOCKH1" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') +
213 $("$MUXD" stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') ))
214 [ "$HBORN" -eq 5 ] || {
215 echo "e2e FAIL: hosts: Ctrl-\\ c left $HBORN sessions across the two daemons, want 5"
216 "$MUXD" stats --sock "$SOCKH1"; "$MUXD" stats --sock "$SOCKH2"; exit 1; }
217 # ...and a SECOND wall finds it, with nothing having been written down.
218 set +e
219 XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 44 \
220 --out "$OUT.hw2cap" --err "$OUT.hw2cap.err" -- "$MUX" > "$OUT.hw2pc" 2>&1 <<'EOF'
221 expect [up] 25000
222 settle 2000 25000
223 send \x1cd
224 waitexit 15000
225 EOF
226 RC=$?
227 set -e
228 [ "$RC" -eq 0 ] || {
229 echo "e2e FAIL: hosts: the second wall exited $RC:"; cat "$OUT.hw2pc"; exit 1; }
230 # `1` is the name `nextFreeName` picks on either daemon — both hold `0` and
231 # one letter — so which host the birth landed on does not change what it is
232 # called, only which stripe carries it.
233 grep -q -- "#1 \[" "$OUT.hw2cap" || {
234 echo "e2e FAIL: hosts: the session Ctrl-\\ c created is not a tile on the next wall:"
235 cat "$OUT.hw2pc"; exit 1; }
236 # ...and then it goes again, so the counts the legs below assert are the
237 # ones this file set up. WHICH of the two hosts the birth landed on depends
238 # on which polled first, and that is a race no leg here has any business
239 # pinning.
240 for _hs in "$SOCKH1" "$SOCKH2"; do
241 if "$MUXD" stats --sock "$_hs" | grep -q 'session 1 '; then
242 pipe_mux "$OUT.hkill" "$OUT.hkill.err" env XDG_STATE_HOME="$HXSTATE" timeout 40 \
243 "$MUX" --sock "$_hs" --session 1
244 pipe_send 'exit 0\n'
245 pipe_waitexit "hosts: the born session"
246 fi
247 done
248 wait_sessions "$SOCKH1" 2 "hosts: daemon 1 back to 0 and b"
249 wait_sessions "$SOCKH2" 2 "hosts: daemon 2 back to 0 and c"
250 ok "every live session of every listed daemon is a tile, n walks them, and a birth writes nothing down"
251
252 # ---- Ctrl-\ x ends a session, and asks first ----------------------------
253 #
254 # `x` is the one chord that destroys something, so it is the one chord that
255 # refuses. Two clients on one session, and the three facts that make the
256 # two-step a two-step rather than a delay:
257 #
258 # * the first `x` is REFUSED, by count, and the shell is still there —
259 # asked of the OS with `kill -0` on the pid the shell itself printed,
260 # not of the daemon that would be reporting on its own refusal.
261 # * the second `x` ends it. The shell is gone from the process table.
262 # * the OTHER client, which typed nothing at all, comes back with the
263 # session's exit status. A hangup is not a detach: it reaches everyone
264 # attached.
265 #
266 # Two ptyclient runs rather than one, because `kill -0` between the two
267 # chords is this script's to do and ptyclient's script cannot stop for it.
268 # The three-second latch means run B has to press `x` twice itself.
269 pipe_mux "$OUT.hxmk" "$OUT.hxmk.err" env XDG_STATE_HOME="$HXSTATE" timeout 40 \
270 "$MUX" --sock "$SOCKH1" --session xb
271 pipe_send 'echo xbpid=$$\n'
272 await_out "$OUT.hxmk" "xbpid=" "xbpid never reached the client"
273 pipe_detach "hosts x setup"
274 wait_grid "$SOCKH1" "xbpid=" "hosts x: session xb's shell pid" xb
275 XBPID=$(dump_session "$SOCKH1" xb | sed -n 's/.*xbpid=\([0-9]*\).*/\1/p' | tail -1)
276 [ -n "$XBPID" ] || { echo "e2e FAIL: hosts x: session xb printed no pid"; exit 1; }
277
278 # The other client: attached, silent, and holding its stdin open so nothing
279 # but the session's own end can bring it back.
280 pipe_mux "$OUT.hxo" "$OUT.hxo.err" env XDG_STATE_HOME="$HXSTATE" timeout 90 \
281 "$MUX" --sock "$SOCKH1" --session xb
282 await_out "$OUT.hxo" "xbpid=" "hosts x: the other client never got the grid"
283
284 set +e
285 XDG_STATE_HOME="$HXSTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 30 \
286 --out "$OUT.hx1cap" --err "$OUT.hx1cap.err" -- \
287 "$MUX" --sock "$SOCKH1" --session xb > "$OUT.hx1pc" 2>&1 <<'EOF'
288 expect xbpid= 20000
289 settle 800 20000
290 send \x1cx
291 expect attached - x again to end 15000
292 settle 600 15000
293 send \x1cd
294 waitexit 10000
295 EOF
296 RC=$?
297 set -e
298 [ "$RC" -eq 0 ] || {
299 echo "e2e FAIL: hosts x: the refusal leg exited $RC (did the first x say who else was there?):"
300 cat "$OUT.hx1pc"; exit 1; }
301 grep -qF -- "[1 other attached - x again to end]" "$OUT.hx1cap" || {
302 echo "e2e FAIL: hosts x: the refusal did not name the count:"
303 grep -ao '\[[0-9]* other[^]]*\]' "$OUT.hx1cap" | head -3; exit 1; }
304 kill -0 "$XBPID" 2>/dev/null || {
305 echo "e2e FAIL: hosts x: the FIRST x ended the shell despite another client"; exit 1; }
306
307 set +e
308 XDG_STATE_HOME="$HXSTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 30 \
309 --out "$OUT.hx2cap" --err "$OUT.hx2cap.err" -- \
310 "$MUX" --sock "$SOCKH1" --session xb > "$OUT.hx2pc" 2>&1 <<'EOF'
311 expect xbpid= 20000
312 settle 800 20000
313 send \x1cx
314 expect attached - x again to end 15000
315 settle 600 15000
316 send \x1cx
317 settle 2000 20000
318 send \x1cd
319 waitexit 10000
320 EOF
321 RC=$?
322 set -e
323 [ "$RC" -eq 0 ] || {
324 echo "e2e FAIL: hosts x: the forcing leg exited $RC:"; cat "$OUT.hx2pc"; exit 1; }
325 wait_pid_gone "$XBPID" "hosts x: the shell after the second x"
326 # The other client was hung up on, with the session's status in hand. 128
327 # is what a shell killed by a signal reports (pty.zig checkExited), and it
328 # is the one number that says the daemon ended the session rather than the
329 # client noticing a closed socket.
330 pipe_waitexit "hosts x: the other client" 128
331 wait_sessions "$SOCKH1" 2 "hosts x: only xb ended"
332 ok "x refuses while others are attached, then ends; the other client sees the exit"
333
334 # ---- a daemon that goes and comes back re-creates NOTHING ---------------
335 #
336 # The model's sharpest edge. Sessions live in the daemon, so a daemon that
337 # dies takes them with it — and the wall, which is a list of DAEMONS, has
338 # nothing left to resurrect them from. The old wall file could and did.
339 #
340 # Two states of one host, each with its own witness:
341 #
342 # * down: ONE stripe, `[unreachable]`, and not one dead tile per session
343 # it used to have.
344 # * back: its own default session and nothing else. `c`, which was live
345 # when the daemon died, is not there — asked of `muxd stats` on the
346 # real daemon, not of the wall that would be reporting on its own
347 # resurrection.
348 assert_stopped "$SOCKH2" "$DH2PID" "hosts restart" "$OUT.h2stop"
349 DH2PID=""
350 set +e
351 XDG_STATE_HOME="$HSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 44 \
352 --out "$OUT.hdcap" --err "$OUT.hdcap.err" -- "$MUX" > "$OUT.hdpc" 2>&1 <<'EOF'
353 expect [unreachable] 25000
354 settle 1000 25000
355 send \x1cd
356 waitexit 15000
357 EOF
358 RC=$?
359 set -e
360 [ "$RC" -eq 0 ] || {
361 echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; }
362 grep -q -- "--sock $SOCKH2 \[unreachable\]" "$OUT.hdcap" || {
363 echo "e2e FAIL: hosts restart: a down daemon is not one stripe naming itself:"
364 cat "$OUT.hdpc"; exit 1; }
365 if grep -q -- "--sock $SOCKH2#c" "$OUT.hdcap"; then
366 echo "e2e FAIL: hosts restart: a down daemon still shows the sessions it had:"
367 cat "$OUT.hdpc"; exit 1
368 fi
369
370 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 never came back" --shell /bin/sh
371 DH2PID=$DPID
372 set +e
373 XDG_STATE_HOME="$HSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 44 \
374 --out "$OUT.hrcap" --err "$OUT.hrcap.err" -- "$MUX" > "$OUT.hrpc" 2>&1 <<'EOF'
375 expect [up] 25000
376 settle 2500 25000
377 send \x1cd
378 waitexit 15000
379 EOF
380 RC=$?
381 set -e
382 [ "$RC" -eq 0 ] || {
383 echo "e2e FAIL: hosts restart: the reborn-host wall exited $RC:"; cat "$OUT.hrpc"; exit 1; }
384 grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || {
385 echo "e2e FAIL: hosts restart: the reborn daemon's own session never became a tile:"
386 cat "$OUT.hrpc"; exit 1; }
387 if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap"; then
388 echo "e2e FAIL: hosts restart: the wall resurrected the session the dead daemon held:"
389 cat "$OUT.hrpc"; exit 1
390 fi
391 # The daemon's own account of it, which is the one that matters: a session
392 # the wall re-created would be here whether or not it ever got a stripe.
393 [ "$("$MUXD" stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "1" ] || {
394 echo "e2e FAIL: hosts restart: the reborn daemon holds more than its own default session:"
395 "$MUXD" stats --sock "$SOCKH2"; exit 1; }
396 ok "a daemon that goes down is one unreachable stripe, and comes back re-creating nothing"
397
398 # ---- hosts rm takes a daemon off the wall and ends nothing --------------
399 #
400 # The file is a VIEW. Removing a line is forgetting where to look, never
401 # an instruction to the daemon — which is why the sessions are asked for
402 # again afterwards and have to still be there.
403 #
404 # The two refusals ride along because they are the same file's rules:
405 # `rm` of a line that is not there is an error naming the file, and `add`
406 # of a spelling with a `#` in it is the grammar's one refusal.
407 # `timeout`, and a verdict of its own: `mux hosts` dials every listed
408 # daemon, and while each dial is bounded inside the binary (hosts_list_ms) a
409 # hang here would hang the whole suite with no line naming it. Bare under
410 # `set -e` a non-zero exit would abort the run with no message at all.
411 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts > "$OUT.hlist" 2>&1 || {
412 echo "e2e FAIL: hosts rm: 'mux hosts' exited nonzero on two live daemons:"
413 cat "$OUT.hlist"; exit 1; }
414 grep -qF -- "--sock $SOCKH1" "$OUT.hlist" || {
415 echo "e2e FAIL: hosts rm: 'mux hosts' does not list the daemon it is about to remove:"
416 cat "$OUT.hlist"; exit 1; }
417 # The COUNT, which is the whole point of the verb — and the two daemons hold
418 # different numbers on purpose: a fixture where every row carried the same
419 # figure could not tell the daemon's own answer from a constant, and one
420 # where every row failed (the only case pinned before) never ran the column
421 # at all. `wait_sessions` above already settled both.
422 for _hc in "$SOCKH1:2" "$SOCKH2:1"; do
423 _hcs=${_hc%:*}; _hcn=${_hc##*:}
424 grep -qxF -- "$(printf -- '--sock %s\t%s' "$_hcs" "$_hcn")" "$OUT.hlist" || {
425 echo "e2e FAIL: hosts: 'mux hosts' does not say $_hcs holds $_hcn sessions:"
426 cat "$OUT.hlist"; exit 1; }
427 done
428 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm" 2>&1 || {
429 echo "e2e FAIL: hosts rm: removing a listed daemon failed:"; cat "$OUT.hrm"; exit 1; }
430 if grep -qF -- "$SOCKH1" "$HSTATE/mux/hosts"; then
431 echo "e2e FAIL: hosts rm: the line is still on the wall:"
432 cat "$HSTATE/mux/hosts"; exit 1
433 fi
434 wait_sessions "$SOCKH1" 2 "hosts rm: daemon 1's sessions must survive being forgotten"
435 wait_grid "$SOCKH1" "hb-pin" "hosts rm: session b's grid outlived the wall line" b
436
437 set +e
438 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm2" 2>&1
439 RC=$?
440 set -e
441 [ "$RC" -eq 1 ] || {
442 echo "e2e FAIL: hosts rm: removing an absent daemon exited $RC, want 1:"
443 cat "$OUT.hrm2"; exit 1; }
444 grep -q "not on the wall" "$OUT.hrm2" || {
445 echo "e2e FAIL: hosts rm: an absent rm never said so:"; cat "$OUT.hrm2"; exit 1; }
446 set +e
447 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts add "--sock $SOCKH1#b" > "$OUT.hadd2" 2>&1
448 RC=$?
449 set -e
450 [ "$RC" -eq 2 ] || {
451 echo "e2e FAIL: hosts add: a '#' spelling exited $RC, want 2:"; cat "$OUT.hadd2"; exit 1; }
452 grep -q "names a session" "$OUT.hadd2" || {
453 echo "e2e FAIL: hosts add: the '#' refusal never named the rule:"; cat "$OUT.hadd2"; exit 1; }
454 if grep -qF -- "$SOCKH1" "$HSTATE/mux/hosts"; then
455 echo "e2e FAIL: hosts add: a refused spelling still edited the file:"
456 cat "$HSTATE/mux/hosts"; exit 1
457 fi
458 # Asking what a command does is not mistyping its verb. `--help` and
459 # `--version` used to be answered inside the `add` branch only, so `mux
460 # hosts --help` exited 2 calling the flag a subcommand that does not exist,
461 # and sent the reader looking for one. Asserted here rather than in a unit
462 # test because the answer is a page on fd 1, which under `zig build test` is
463 # the runner's own protocol stream.
464 for _hh in "--help" "-h"; do
465 set +e
466 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts "$_hh" > "$OUT.hhelp" 2>&1
467 RC=$?
468 set -e
469 [ "$RC" -eq 0 ] || {
470 echo "e2e FAIL: hosts $_hh: exited $RC, want 0:"; cat "$OUT.hhelp"; exit 1; }
471 grep -q "mux hosts add SPELLING" "$OUT.hhelp" || {
472 echo "e2e FAIL: hosts $_hh: answered something other than the usage:"
473 cat "$OUT.hhelp"; exit 1; }
474 done
475 set +e
476 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts --version > "$OUT.hver" 2>&1
477 RC=$?
478 set -e
479 [ "$RC" -eq 0 ] || {
480 echo "e2e FAIL: hosts --version: exited $RC, want 0:"; cat "$OUT.hver"; exit 1; }
481 grep -q "^mux " "$OUT.hver" || {
482 echo "e2e FAIL: hosts --version: did not name the binary and its version:"
483 cat "$OUT.hver"; exit 1; }
484
485 assert_stopped "$SOCKH1" "$DH1PID" "hosts rm" "$OUT.h1stop"
486 DH1PID=""
487 assert_stopped "$SOCKH2" "$DH2PID" "hosts rm" "$OUT.h2stop2"
488 DH2PID=""
489 ok "hosts rm takes the daemon off the wall and ends nothing"
test/e2e_09_wallhist.sh
Old New
@@ -1,636 +0,0 @@
1 # shellcheck shell=sh
2 # e2e_09_wallhist.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may
4 # assume and what it must register.
5 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
6 # own, for the dynamic-wall leg's reason turned up one notch: what these
7 # two blocks read back is the wall FILE, and every `mux` in this suite now
8 # writes a tile into the shared $XDG_STATE_HOME — so a file read out of
9 # that one would be every other block's attaches as much as this one's.
10 # The daemon is separate because the tile spelling contains the socket
11 # path, which is what the assertions grep for.
12 SOCK40="${TMPDIR:-/tmp}/muxd-e2e-wallhist-$$.sock"
13 defer_sock "$SOCK40"
14 WHSTATE="${TMPDIR:-/tmp}/mux-e2e-wallhist-state-$$"
15 defer_rm "$WHSTATE"
16 WHWALL="$WHSTATE/mux/wall"
17 # A state home whose wall FILE is a directory: the unwritable case, which
18 # has to warn and let the attach happen anyway.
19 WHBAD="${TMPDIR:-/tmp}/mux-e2e-wallbad-$$"
20 defer_rm "$WHBAD"
21 # And a third, for the `x` block: its wall is built by two attaches and
22 # then eaten by `x`, so it must start empty and stay its own.
23 WHXSTATE="${TMPDIR:-/tmp}/mux-e2e-wallx-state-$$"
24 defer_rm "$WHXSTATE"
25 # The convergence block: `mux TARGET` is a wall of one tile whose rect is
26 # the whole terminal. Three daemons and three state homes, each of them its own
27 # for the same reason the attach-history block's is — what these legs read
28 # back is the wall FILE, and the shared $XDG_STATE_HOME is every socket the
29 # suite has ever attached to.
30 SOCK45="${TMPDIR:-/tmp}/muxd-e2e-converge-$$.sock"
31 defer_sock "$SOCK45"
32 CVSTATE="${TMPDIR:-/tmp}/mux-e2e-converge-state-$$"
33 defer_rm "$CVSTATE"
34 CVWALL="$CVSTATE/mux/wall"
35 # The ring-grows-the-wall leg needs TWO state homes: one for the client
36 # under test, and one for the setup client whose attach must NOT leave a
37 # tile the leg would then find already there. That absence is the case
38 # under test — a sibling the daemon knows and the wall does not.
39 SOCK46="${TMPDIR:-/tmp}/muxd-e2e-ringgrow-$$.sock"
40 defer_sock "$SOCK46"
41 RGSTATE="${TMPDIR:-/tmp}/mux-e2e-ringgrow-state-$$"
42 defer_rm "$RGSTATE"
43 RGOTHER="${TMPDIR:-/tmp}/mux-e2e-ringgrow-other-$$"
44 defer_rm "$RGOTHER"
45 # The exit-semantics leg: a session ending in the focused tile.
46 SOCK47="${TMPDIR:-/tmp}/muxd-e2e-exitsem-$$.sock"
47 defer_sock "$SOCK47"
48 XESTATE="${TMPDIR:-/tmp}/mux-e2e-exitsem-state-$$"
49 defer_rm "$XESTATE"
50
51 # ---- the wall is attach HISTORY -----------------------------------------
52 #
53 # The mechanical rule (wall-home-screen spec, phase 2): an attach that
54 # CLAIMS the grid writes its tile; one that does not, does not. Four
55 # witnesses, in one state home nothing else writes to:
56 #
57 # * a `mux` attach adds exactly one line, spelled `#0` — the RESOLVED
58 # default name, because a wall line has to be a spelling a user could
59 # type back, not the empty wire name the attach frame carries;
60 # * a second attach to the same session adds nothing (dedup, byte-exact);
61 # * `muxa` drives the SAME session and the file is byte-identical after
62 # — the 0x0 invariant, made testable rather than merely stated. The
63 # hash is anchored on a marker the agent actually landed, so a muxa
64 # that did nothing at all cannot pass this leg by doing nothing;
65 # * a session reached by the `Ctrl-\ c` chord earns its own tile, which
66 # is the "the wall grows by the truth" clause — those re-dials happen
67 # inside client.attach's own loop and never come back through main.
68 #
69 # Then the two failure faces: an unwritable wall file warns and does NOT
70 # stop the attach, and 'mux wall add' refuses a bad spelling by name
71 # without touching the file.
72 start_daemon "$SOCK40" "$OUT.wh.d" "attach-history daemon never bound" --shell /bin/sh
73 D37PID=$DPID
74
75 pipe_mux "$OUT.wh1" "$OUT.wh1.err" env XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40"
76 pipe_send 'printf "wh1-%%s\\n" pin\n'
77 await_out "$OUT.wh1" "wh1-pin" "wh1-pin never reached the client"
78 pipe_detach
79 wait_grid "$SOCK40" "wh1-pin" "attach history: the default session's marker"
80 grep -qx -- "--sock $SOCK40#0" "$WHWALL" || {
81 echo "e2e FAIL: attach history: the attach wrote no tile (or not '#0'):"
82 cat "$WHWALL" 2>&1; exit 1; }
83 _wh_n=$(wc -l < "$WHWALL")
84 [ "$_wh_n" -eq 1 ] || {
85 echo "e2e FAIL: attach history: one attach wrote $_wh_n lines:"
86 cat "$WHWALL"; exit 1; }
87
88 pipe_mux "$OUT.wh2" "$OUT.wh2.err" env XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40"
89 pipe_send 'printf "wh2-%%s\\n" pin\n'
90 await_out "$OUT.wh2" "wh2-pin" "wh2-pin never reached the client"
91 pipe_detach
92 wait_grid "$SOCK40" "wh2-pin" "attach history: the second attach's marker"
93 _wh_n=$(wc -l < "$WHWALL")
94 [ "$_wh_n" -eq 1 ] || {
95 echo "e2e FAIL: attach history: a second attach to the same session made"
96 echo " $_wh_n lines — dedup is not deduping:"
97 cat "$WHWALL"; exit 1; }
98
99 # muxa: the 0x0 invariant, as a hash. Its marker is the anchor — a muxa
100 # that never attached would leave the file identical too, and this leg
101 # would pass while proving nothing.
102 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
103 XDG_STATE_HOME="$WHSTATE" "$MUXA" send 'printf "whagent-%s\n" pin\n' \
104 --sock "$SOCK40" > "$OUT.whb" 2> "$OUT.whb.err"
105 wait_grid "$SOCK40" "whagent-pin" "attach history: the agent's own marker"
106 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
107 echo "e2e FAIL: attach history: muxa attached at 0x0 and still wrote a tile"
108 echo " — 'muxa never touches the wall' is broken:"
109 cat "$WHWALL"; exit 1; }
110
111 # Ctrl-\ c: the chord re-dials full size, so the session it creates earns a
112 # tile like any other attach. The chord gets a write of its own — a chord
113 # ends its chunk, and a piped client reads chunks.
114 { sleep 1; printf '\034c'; sleep 3; printf 'printf "whc-%%s\\n" pin\n'; \
115 sleep 2; printf '\034\034'; } | \
116 XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40" \
117 > "$OUT.whc" 2> "$OUT.whc.err"
118 wait_grid "$SOCK40" "whc-pin" "attach history: the chord-created session's marker" 1
119 grep -qx -- "--sock $SOCK40#1" "$WHWALL" || {
120 echo "e2e FAIL: attach history: a session visited by Ctrl-\\ c earned no tile:"
121 cat "$WHWALL"; cat "$OUT.whc.err"; exit 1; }
122
123 # An unwritable wall file: a DIRECTORY where the file belongs. One warning,
124 # and an attach that happened anyway — best effort means the attach is the
125 # act and the tile is only the record.
126 mkdir -p "$WHBAD/mux/wall"
127 pipe_mux "$OUT.whro" "$OUT.whro.err" env XDG_STATE_HOME="$WHBAD" timeout 40 "$MUX" --sock "$SOCK40"
128 pipe_send 'printf "whro-%%s\\n" pin\n'
129 await_out "$OUT.whro" "whro-pin" "whro-pin never reached the client"
130 pipe_detach
131 grep -q "wall not updated" "$OUT.whro.err" || {
132 echo "e2e FAIL: attach history: an unwritable wall file said nothing:"
133 cat "$OUT.whro.err"; exit 1; }
134 wait_grid "$SOCK40" "whro-pin" "attach history: the attach an unwritable wall did not block"
135
136 # The self-attach refusal exits BEFORE the dial, so it must exit before the
137 # write too: a tile is the record of an attach, and this one never happened.
138 # Spelled with the environment rather than a session shell because that is
139 # all the refusal reads — and it keeps the assertion a hash of one file
140 # rather than a search for a line other legs also write.
141 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
142 set +e
143 MUX_SOCK="$SOCK40" MUX_SESSION=0 XDG_STATE_HOME="$WHSTATE" \
144 "$MUX" --sock "$SOCK40" > "$OUT.whself" 2>&1
145 RC=$?
146 set -e
147 [ "$RC" -eq 2 ] || {
148 echo "e2e FAIL: attach history: the self-attach refusal exited $RC, want 2:"
149 cat "$OUT.whself"; exit 1; }
150 grep -q "this shell is inside that session" "$OUT.whself" || {
151 echo "e2e FAIL: attach history: the self-attach leg refused for another reason:"
152 cat "$OUT.whself"; exit 1; }
153 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
154 echo "e2e FAIL: attach history: a REFUSED self-attach still wrote a tile:"
155 cat "$WHWALL"; exit 1; }
156
157 # 'mux wall add': validation at ADD time, in the grammar's own words, and
158 # a refused spelling writes nothing at all.
159 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
160 set +e
161 XDG_STATE_HOME="$WHSTATE" "$MUX" wall add "box#has space" > "$OUT.whadd" 2>&1
162 RC=$?
163 set -e
164 [ "$RC" -ne 0 ] || {
165 echo "e2e FAIL: attach history: 'mux wall add' accepted a bad session name"; exit 1; }
166 grep -q "bad session name" "$OUT.whadd" || {
167 echo "e2e FAIL: attach history: a refused add never named its reason:"
168 cat "$OUT.whadd"; exit 1; }
169 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
170 echo "e2e FAIL: attach history: a refused add still edited the file:"
171 cat "$WHWALL"; exit 1; }
172
173 # ...and the pair that works, including `rm` of what is not there.
174 XDG_STATE_HOME="$WHSTATE" "$MUX" wall add "whbox#work" > "$OUT.whadd" 2>&1
175 grep -qx "whbox#work" "$WHWALL" || {
176 echo "e2e FAIL: attach history: 'mux wall add' added nothing:"; cat "$WHWALL"; exit 1; }
177 set +e
178 XDG_STATE_HOME="$WHSTATE" "$MUX" wall rm "whbox#work" > "$OUT.whrm" 2>&1
179 RC=$?
180 set -e
181 [ "$RC" -eq 0 ] || {
182 echo "e2e FAIL: attach history: 'mux wall rm' exited $RC:"; cat "$OUT.whrm"; exit 1; }
183 if grep -q "whbox#work" "$WHWALL"; then
184 echo "e2e FAIL: attach history: 'mux wall rm' left the line behind:"
185 cat "$WHWALL"; exit 1
186 fi
187 set +e
188 XDG_STATE_HOME="$WHSTATE" "$MUX" wall rm "whbox#work" > "$OUT.whrm" 2>&1
189 RC=$?
190 set -e
191 [ "$RC" -ne 0 ] || {
192 echo "e2e FAIL: attach history: removing an absent tile exited 0"; exit 1; }
193 grep -q "not on the wall" "$OUT.whrm" || {
194 echo "e2e FAIL: attach history: an absent rm never said so:"; cat "$OUT.whrm"; exit 1; }
195 ok "the wall is attach history: mux adds, muxa never does, add/rm edit it"
196
197 # ---- `x` forgets a tile, and never kills its session --------------------
198 #
199 # The wall this leg puts up is one nothing typed by hand: two attaches
200 # BUILT it, which is the model's whole claim. Then `Ctrl-\ x` is asked for
201 # the two things it must do and the one it must not — remove the line,
202 # re-cut the wall, and leave the session running ("remove is detach").
203 #
204 # Two ptyclient runs rather than one, because the file is the artifact:
205 # what a run did is only readable after it has exited, so the one-tile
206 # state between them is the second run's input.
207 pipe_mux "$OUT.whxa" "$OUT.whxa.err" env XDG_STATE_HOME="$WHXSTATE" timeout 40 "$MUX" --sock "$SOCK40" --session xa
208 pipe_send 'printf "whxa-%%s\\n" pin\n'
209 await_out "$OUT.whxa" "whxa-pin" "whxa-pin never reached the client"
210 pipe_detach
211 wait_grid "$SOCK40" "whxa-pin" "x forgets: session xa's marker" xa
212 pipe_mux "$OUT.whxb" "$OUT.whxb.err" env XDG_STATE_HOME="$WHXSTATE" timeout 40 "$MUX" --sock "$SOCK40" --session xb
213 pipe_send 'printf "whxb-%%s\\n" pin\n'
214 await_out "$OUT.whxb" "whxb-pin" "whxb-pin never reached the client"
215 pipe_detach
216 wait_grid "$SOCK40" "whxb-pin" "x forgets: session xb's marker" xb
217 _whx_n=$(wc -l < "$WHXSTATE/mux/wall")
218 [ "$_whx_n" -eq 2 ] || {
219 echo "e2e FAIL: x forgets: two attaches built a wall of $_whx_n tiles:"
220 cat "$WHXSTATE/mux/wall"; exit 1; }
221
222 # No operands: the wall this shows is the file, which is the point. The
223 # state home rides on PTYCLIENT itself and is inherited by the child —
224 # ptyclient execs its `--` argv as given, so an `env` wrapper in front of
225 # `mux` is not a command it runs, it is a program it fails to find.
226 #
227 # ONE marker expected, then `settle` — the pattern every other two-tile
228 # ptyclient leg here uses, and for a reason review reproduced on this
229 # suite: the tiles are independent threads, `expect` consumes forward, and
230 # whichever stripe painted second leaves the other one's marker BEHIND the
231 # cursor. Two consecutive expects against two pumps is a coin flip that
232 # spends its whole budget waiting for bytes that already went past. What
233 # the `Ctrl-\ x` below needs is a wall that is up and settled, which the
234 # single expect plus `settle` gives; the tile it forgets is the FOCUSED
235 # one, tile 0, whose line the file is then asserted NOT to hold.
236 set +e
237 XDG_STATE_HOME="$WHXSTATE" timeout 60 "$PTYCLIENT" --cols 40 --rows 30 \
238 --out "$OUT.whxcap" --err "$OUT.whxcap.err" -- \
239 "$MUX" wall > "$OUT.whxpc" 2>&1 <<'EOF'
240 expect whxb-pin 20000
241 settle 800 20000
242 send \x1cx
243 settle 800 20000
244 send \x1cd
245 waitexit 10000
246 EOF
247 RC=$?
248 set -e
249 [ "$RC" -eq 0 ] || {
250 echo "e2e FAIL: x forgets: ptyclient leg exited $RC (did \\x1cx forget the tile?):"
251 cat "$OUT.whxpc"; exit 1; }
252 # The line is gone and the survivor kept its place — `Ctrl-\ x` is an
253 # ordered removal, not a rewrite of the wall.
254 _whx_n=$(wc -l < "$WHXSTATE/mux/wall")
255 [ "$_whx_n" -eq 1 ] || {
256 echo "e2e FAIL: x forgets: the wall file holds $_whx_n lines after one \\x1cx:"
257 cat "$WHXSTATE/mux/wall"; exit 1; }
258 grep -qx -- "--sock $SOCK40#xb" "$WHXSTATE/mux/wall" || {
259 echo "e2e FAIL: x forgets: the wrong line survived:"
260 cat "$WHXSTATE/mux/wall"; exit 1; }
261 # The survivor took the whole terminal: its marker painted a SECOND time
262 # after the re-cut, counted because one paint is the wall's first draw and
263 # says nothing about the hole closing around the survivor.
264 _whx_bars=$(grep -o "whxb-pin" "$OUT.whxcap" | wc -l)
265 [ "$_whx_bars" -ge 2 ] || {
266 echo "e2e FAIL: x forgets: the surviving tile's marker painted $_whx_bars time(s),"
267 echo " so the wall was never re-cut around the hole:"
268 cat "$OUT.whxpc"; exit 1; }
269 # ...and the SESSION is untouched: it still answers, and it still holds
270 # what it held. "Remove is detach" — the tile went, the session did not.
271 # xa was 80-wide from its pipe attach; this 40-wide wall resized it on
272 # hydrate, and the detach left that size behind.
273 XDG_STATE_HOME="$WHSTATE" "$MUXA" status --sock "$SOCK40" --session xa > "$OUT.whxst" 2>&1
274 grep -q '"cols":40' "$OUT.whxst" || {
275 echo "e2e FAIL: x forgets: session xa stopped answering — \\x1cx killed it:"
276 cat "$OUT.whxst"; exit 1; }
277 wait_grid "$SOCK40" "whxa-pin" "x forgets: xa's grid outlived its tile" xa
278
279 # The last tile: an empty wall SAYS so rather than going blank.
280 set +e
281 XDG_STATE_HOME="$WHXSTATE" timeout 60 "$PTYCLIENT" --cols 40 --rows 30 \
282 --out "$OUT.whxcap2" --err "$OUT.whxcap2.err" -- \
283 "$MUX" wall > "$OUT.whxpc2" 2>&1 <<'EOF'
284 expect whxb-pin 20000
285 settle 800 20000
286 send \x1cx
287 settle 800 20000
288 send \x1cd
289 waitexit 10000
290 EOF
291 RC=$?
292 set -e
293 [ "$RC" -eq 0 ] || {
294 echo "e2e FAIL: x forgets: the last-tile leg exited $RC:"
295 cat "$OUT.whxpc2"; exit 1; }
296 grep -q "nothing left to show" "$OUT.whxcap2" || {
297 echo "e2e FAIL: x forgets: forgetting the last tile left a blank terminal"
298 echo " with nothing said:"; cat "$OUT.whxpc2"; exit 1; }
299 [ ! -s "$WHXSTATE/mux/wall" ] || {
300 echo "e2e FAIL: x forgets: the last \\x1cx left lines behind:"
301 cat "$WHXSTATE/mux/wall"; exit 1; }
302 # The phantom tile, while this daemon is FULL: this leg's own four (0, 1,
303 # xa, xb) plus the 28 filled here are max_sessions, so a FIRST attach to
304 # one more name is refused — and a refusal must leave no line behind. Its
305 # state home is neither $WHSTATE nor $WHXSTATE, both of whose walls are
306 # asserted on. This is the case that made the seam move off "the dial
307 # succeeded" — a dial that comes up is not an attach that landed, and only
308 # a SWITCH's refusal ever had somewhere to undo the write from. The leg
309 # cannot pass vacuously: a daemon with room would accept the attach and
310 # the rc check below would fail loudly.
311 fill_sessions "$SOCK40" "$OUT.whfill" fill 4 31
312 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
313 set +e
314 { sleep 1; printf '\034\034'; } | XDG_STATE_HOME="$WHSTATE" timeout 40 \
315 "$MUX" --sock "$SOCK40" --session phantom > "$OUT.whph" 2>&1
316 RC=$?
317 set -e
318 [ "$RC" -ne 0 ] || {
319 echo "e2e FAIL: x forgets: a 33rd session was created on a full daemon,"
320 echo " so the phantom-tile leg proved nothing:"; cat "$OUT.whph"; exit 1; }
321 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
322 echo "e2e FAIL: x forgets: a REFUSED first attach recorded a phantom tile:"
323 cat "$WHWALL"; exit 1; }
324 assert_stopped "$SOCK40" "$D37PID" "x forgets" "$OUT.whstop"
325 D37PID=""
326 ok "\\x1cx forgets a tile and leaves its session running; a refusal records nothing"
327
328 # ---- `mux TARGET` is a wall of one tile ---------------------------------
329 #
330 # The convergence's headline: `mux` dials and attaches exactly as it always
331 # did, and what it runs is the WALL — one tile whose rect is the whole
332 # terminal, no label bar, byte-identical to a plain client. `Ctrl-\ w`
333 # folds the SAVED wall into this one: it hydrates the file's tiles beside
334 # the tile this run is standing on, and what appears is THE wall — this
335 # run's tile plus the ones the saved file already holds.
336 #
337 # Four claims, and each needs a different kind of witness:
338 #
339 # * the wall `Ctrl-\ w` folds in is the SAVED one. A tile written by an
340 # EARLIER `mux` (the `#side` attach below, into this leg's own state
341 # home) paints a stripe here, in a process that was never told about
342 # it on its command line. A wall that only ever showed this run's own
343 # tile would satisfy every other assertion in this leg.
344 # * the focus stays on the entry tile, and that costs NOTHING at the
345 # daemon. The attach counter is sampled across the fold and the
346 # injection and moves only for the two real attaches (the entry tile
347 # and the saved tile the fold hydrated): focus is client-local and
348 # sends nothing on the wire.
349 # * `Ctrl-\ d` leaves MUX. There is no child and no second screen; the
350 # one-tile wall is the program, and the detach chord is the way out.
351 # * ...and the attach that opened all this earned exactly one line in the
352 # file, spelled `#0`, beside the `#side` that was already there.
353 #
354 # A state home of its own: the wall `Ctrl-\ w` folds in is the saved one,
355 # and the shared $XDG_STATE_HOME is every socket the suite has ever
356 # attached to.
357 start_daemon "$SOCK45" "$OUT.cv.d" "convergence daemon never bound" --shell /bin/sh
358 D45PID=$DPID
359
360 # The tile that is already on the wall before this leg's client starts. A
361 # piped `mux`, so its attach writes the line and then goes: what the leg
362 # asserts on is the FILE it left behind, not a process still holding it.
363 pipe_mux "$OUT.cvside" "$OUT.cvside.err" env XDG_STATE_HOME="$CVSTATE" timeout 40 "$MUX" --sock "$SOCK45" --session side
364 pipe_send 'printf "cvside-%%s\\n" pin\n'
365 await_out "$OUT.cvside" "cvside-pin" "cvside-pin never reached the client"
366 pipe_detach
367 wait_grid "$SOCK45" "cvside-pin" "convergence: the saved tile's marker" side
368 grep -qx -- "--sock $SOCK45#side" "$CVWALL" || {
369 echo "e2e FAIL: convergence: the setup attach wrote no tile to fold in:"
370 cat "$CVWALL" 2>&1; exit 1; }
371
372 # The whole run's budget, and every one of the three is named: the entry
373 # tile's attach, the saved tile's when the fold hydrates it, and the
374 # `muxa send` below, which is an attach like any other. The fold itself
375 # is a real attach (it dials the saved tile's session); the focus move and
376 # the leave add nothing, and a cumulative counter is what says so — a
377 # gauge would let a dial that opened and closed between two samples hide
378 # inside it.
379 CVATT_BEFORE=$(attaches_now "$SOCK45")
380 ( sleep 6; "$MUXA" send 'printf "cvret-%s\n" pin\n' \
381 --sock "$SOCK45" --session 0 > "$OUT.cvinj" 2>&1 ) &
382 CVINJPID=$!
383 defer_kill "$CVINJPID"
384 set +e
385 # tall: Ctrl-\ w folds in a saved tile, making two at 100x30 (100 >= 60);
386 # 70x36 stays stacked (70 < 72) and fits the bar label the assertion greps
387 XDG_STATE_HOME="$CVSTATE" timeout 90 "$PTYCLIENT" --cols 70 --rows 36 \
388 --out "$OUT.cvcap" --err "$OUT.cvcap.err" \
389 -- "$MUX" --sock "$SOCK45" > "$OUT.cvpc" 2>&1 <<'EOF'
390 expect \x1b[?1049h 15000
391 settle 400 15000
392 send printf 'cvhome-%s\\n' pin\n
393 expect cvhome-pin 15000
394 settle 400 15000
395 send \x1cw
396 expect cvside-pin 20000
397 settle 900 20000
398 expect cvret-pin 20000
399 settle 500 15000
400 send \x1cd
401 waitexit 10000
402 EOF
403 RC=$?
404 set -e
405 wait "$CVINJPID" 2>/dev/null || true
406 [ "$RC" -eq 0 ] || {
407 echo "e2e FAIL: convergence: ptyclient leg exited $RC (did \\x1cw fold, did \\x1cd leave?):"
408 cat "$OUT.cvpc"; cat "$OUT.cvinj" 2>/dev/null; exit 1; }
409 # The saved tile really painted a STRIPE, which only a wall draws — a bar
410 # with a spelling and a state word in it. A bare marker grep would be
411 # satisfied by the setup attach's own capture bleeding through.
412 grep -q -- "--sock $SOCK45#side \[up\]" "$OUT.cvcap" || {
413 echo "e2e FAIL: convergence: the saved wall's tile never became a stripe:"
414 cat "$OUT.cvpc"; exit 1; }
415 grep -q -- "--sock $SOCK45#0 \[up\]" "$OUT.cvcap" || {
416 echo "e2e FAIL: convergence: the attached tile never became a stripe:"
417 cat "$OUT.cvpc"; exit 1; }
418 # The fold was a real attach (the saved tile dialled) and the injection was
419 # another; the focus move and the leave added nothing. THREE is the whole
420 # budget — entry tile, hydrated saved tile, injector — and a focus that
421 # re-dialled would make it four.
422 CVATT=$(attaches_now "$SOCK45")
423 assert_attach_delta "$CVATT_BEFORE" "$CVATT" 3 "convergence"
424 # The file: one line for the attach this run made, beside the one it found.
425 _cv_n=$(wc -l < "$CVWALL")
426 [ "$_cv_n" -eq 2 ] || {
427 echo "e2e FAIL: convergence: the wall file holds $_cv_n lines, want 2:"
428 cat "$CVWALL"; exit 1; }
429 grep -qx -- "--sock $SOCK45#0" "$CVWALL" || {
430 echo "e2e FAIL: convergence: the attach earned no tile of its own:"
431 cat "$CVWALL"; exit 1; }
432 assert_stopped "$SOCK45" "$D45PID" "convergence" "$OUT.cvstop"
433 D45PID=""
434 ok "mux is the wall: Ctrl-\\ w folds in the saved tiles, the focus stays put, Ctrl-\\ d leaves"
435
436 # ---- the ring GROWS the wall, and only when it has to --------------------
437 #
438 # The spec's `n`/`p` rule, which phase 3c is where it lands: those chords
439 # move the focus around the DAEMON'S session ring, not around the wall's
440 # tiles. A sibling that already has a tile is an instant focus move; one
441 # that does not GETS one, because visiting it is an attach and attach adds.
442 #
443 # The leg is built so the two halves are the SAME chord pressed twice, and
444 # the difference between them is the whole assertion:
445 #
446 # * `\x1cn` from session 0 lands on `two`, which this run has never seen
447 # — its tile was written into a DIFFERENT state home by the setup
448 # client, so the daemon knows the session and this wall does not. One
449 # dial, one attach, one new line in the file, and the marker typed
450 # afterwards lands in `two`'s shell.
451 # * `\x1cn` again wraps back to 0, which now HAS a tile. Nothing is
452 # dialled: the counter must not move across that step, and the marker
453 # lands back in session 0.
454 #
455 # The attach budget for the run is therefore exactly 2 (the entry tile and
456 # the tile the first `n` grew), and the second `n` is the one that must be
457 # free. Sampled around the whole ptyclient run rather than around each
458 # step, because a step that dialled and closed between two samples would
459 # hide inside a gauge — the counter is cumulative and cannot.
460 start_daemon "$SOCK46" "$OUT.rg.d" "ring-grow daemon never bound" --shell /bin/sh
461 D46PID=$DPID
462
463 # `two` exists on the DAEMON but not in this leg's wall: a separate state
464 # home is what makes the setup client's own tile invisible here, so the
465 # first `n` meets a sibling with no tile — which is the case under test.
466 pipe_mux "$OUT.rgtwo" "$OUT.rgtwo.err" env XDG_STATE_HOME="$RGOTHER" timeout 40 "$MUX" --sock "$SOCK46" --session two
467 pipe_send 'printf "rgtwo-%%s\\n" pin\n'
468 await_out "$OUT.rgtwo" "rgtwo-pin" "rgtwo-pin never reached the client"
469 pipe_detach
470 wait_grid "$SOCK46" "rgtwo-pin" "ring grow: the untiled sibling's marker" two
471 grep -qx -- "--sock $SOCK46#two" "$RGSTATE/mux/wall" 2>/dev/null && {
472 echo "e2e FAIL: ring grow: the sibling already had a tile, so the ADD is vacuous:"
473 cat "$RGSTATE/mux/wall"; exit 1; }
474
475 RGATT_BEFORE=$(attaches_now "$SOCK46")
476 set +e
477 # tall: Ctrl-\ n grows the wall to two tiles at 100x30 (100 >= 60)
478 XDG_STATE_HOME="$RGSTATE" timeout 90 "$PTYCLIENT" --cols 40 --rows 30 \
479 --out "$OUT.rgcap" --err "$OUT.rgcap.err" \
480 -- "$MUX" --sock "$SOCK46" > "$OUT.rgpc" 2>&1 <<'EOF'
481 expect \x1b[?1049h 15000
482 settle 400 15000
483 send printf 'rgzero-%s\\n' pin\n
484 expect rgzero-pin 15000
485 settle 400 15000
486 send \x1cn
487 expect rgtwo-pin 20000
488 settle 500 15000
489 send printf 'rghere-%s\\n' pin\n
490 expect rghere-pin 15000
491 settle 400 15000
492 send \x1cn
493 expect rgzero-pin 20000
494 settle 500 15000
495 send printf 'rgback-%s\\n' pin\n
496 expect rgback-pin 15000
497 settle 400 15000
498 send \x1c\x1c
499 waitexit 10000
500 EOF
501 RC=$?
502 set -e
503 RGATT=$(attaches_now "$SOCK46")
504 [ "$RC" -eq 0 ] || {
505 echo "e2e FAIL: ring grow: ptyclient leg exited $RC (did \\x1cn step the ring?):"
506 cat "$OUT.rgpc"; exit 1; }
507 # TWO attaches for the whole run: the entry tile's, and the one the first
508 # `n` grew. A `n` that re-dialled the tile it already had would make it
509 # three, whichever way it closed the old one first.
510 assert_attach_delta "$RGATT_BEFORE" "$RGATT" 2 "ring grow"
511 # The wall grew by the truth: the sibling the ring visited earned a line.
512 grep -qx -- "--sock $SOCK46#two" "$RGSTATE/mux/wall" || {
513 echo "e2e FAIL: ring grow: the visited sibling earned no tile:"
514 cat "$RGSTATE/mux/wall" 2>&1; exit 1; }
515 _rg_n=$(wc -l < "$RGSTATE/mux/wall")
516 [ "$_rg_n" -eq 2 ] || {
517 echo "e2e FAIL: ring grow: the wall file holds $_rg_n lines, want 2:"
518 cat "$RGSTATE/mux/wall"; exit 1; }
519 # Where the markers landed says the focus really MOVED rather than the wall
520 # having gone on typing at one connection. Each session holds its own and
521 # not its neighbour's.
522 timeout 20 "$MUXA" capture --sock "$SOCK46" --session two > "$OUT.rgcapt" 2>&1
523 grep -q "rghere-pin" "$OUT.rgcapt" || {
524 echo "e2e FAIL: ring grow: the marker typed after the first n missed session two:"
525 cat "$OUT.rgcapt"; exit 1; }
526 grep -q "rgback-pin" "$OUT.rgcapt" && {
527 echo "e2e FAIL: ring grow: the marker typed after the wrap landed in two:"
528 cat "$OUT.rgcapt"; exit 1; }
529 timeout 20 "$MUXA" capture --sock "$SOCK46" --session 0 > "$OUT.rgcap0" 2>&1
530 grep -q "rgback-pin" "$OUT.rgcap0" || {
531 echo "e2e FAIL: ring grow: the wrap did not come back to session 0:"
532 cat "$OUT.rgcap0"; exit 1; }
533 assert_stopped "$SOCK46" "$D46PID" "ring grow" "$OUT.rgstop"
534 D46PID=""
535 ok "Ctrl-\\ n grows the wall for a sibling with no tile, and is free for one that has"
536
537 # ---- a session that ends in the focused tile ---------------------------
538 #
539 # The exit rule the convergence had to choose, stated in decisions.md and
540 # pinned here: a session that ends in the focused tile ENDS MUX when it was
541 # the wall's only tile, and LEAVES THE FOCUS TO A NEIGHBOUR when it was not.
542 #
543 # Both halves in one block, because either alone is satisfied by a bug:
544 #
545 # * one tile. `exit 7` in a piped `mux` — the shape every script in the
546 # world uses — and the code comes back 7. Graded against the mutation
547 # (`endAction` dropping to the wall whatever the tile count): `mux`
548 # comes back 0, because the wall it dropped to reads this script's
549 # closed stdin as a wall nobody is left to steer. Zero is not seven and
550 # the leg says so; 124 is the other shape the same bug can take.
551 # * two tiles. `\x1cc` adds one and focuses it, the shell in it exits, and
552 # the tile vanishes — a dead stripe for a cleanly exited shell is noise
553 # — leaving the survivor the whole screen. Then `\x1cd` leaves — exit
554 # 0, not 7 and not the shell's anything, because leaving is the user's
555 # act and not the session's.
556 # * two tiles and a PIPED client whose stdin has already gone. The case
557 # review reproduced as a hang, and the reason the rule's second half is
558 # stated in `endAction` rather than at the read that notices EOF.
559 start_daemon "$SOCK47" "$OUT.xe.d" "exit-semantics daemon never bound" --shell /bin/sh
560 D47PID=$DPID
561
562 # A wall it dropped to instead would read as 124: it never leaves, because
563 # nothing closes its stdin under it.
564 pipe_mux "$OUT.xe1" "$OUT.xe1.err" env XDG_STATE_HOME="$XESTATE" timeout 40 \
565 "$MUX" --sock "$SOCK47"
566 pipe_send 'exit 7\n'
567 pipe_waitexit "exit semantics: the only tile's shell exited 7, and mux" 7
568
569 set +e
570 # tall: Ctrl-\ c creates a second tile at 100x30 (100 >= 60)
571 XDG_STATE_HOME="$XESTATE" timeout 90 "$PTYCLIENT" --cols 40 --rows 30 \
572 --out "$OUT.xecap" --err "$OUT.xecap.err" \
573 -- "$MUX" --sock "$SOCK47" > "$OUT.xepc" 2>&1 <<'EOF'
574 expect \x1b[?1049h 15000
575 settle 400 15000
576 send printf 'xehome-%s\\n' pin\n
577 expect xehome-pin 15000
578 settle 400 15000
579 send \x1cc
580 expect \x1b[2J 15000
581 settle 600 15000
582 send exit 3\n
583 expect xehome-pin 20000
584 settle 800 20000
585 send \x1cd
586 waitexit 10000
587 EOF
588 RC=$?
589 set -e
590 [ "$RC" -eq 0 ] || {
591 echo "e2e FAIL: exit semantics: the multi-tile leg exited $RC (did the focus"
592 echo " move to the neighbour, and did \x1cd leave it?):"
593 cat "$OUT.xepc"; exit 1; }
594 # The exited tile vanished — a dead stripe for a cleanly exited shell is
595 # noise — so there is no `[exited]` bar to read, and the survivor took the
596 # whole screen: a one-tile wall draws no label bar. The marker it repainted
597 # is what `expect xehome-pin` above caught; the grid check below is the
598 # same fact from the daemon's side.
599 # The session that did NOT end is still there: a shell exiting in the
600 # focused tile ends that session and nothing else.
601 wait_grid "$SOCK47" "xehome-pin" "exit semantics: session 0 outlived its neighbour"
602
603 # The third case, and the one review REPRODUCED as a hang: two tiles, but
604 # a PIPED client whose stdin has already closed when the focused session
605 # ends. Moving the focus to a neighbour is not an answer there — a wall
606 # nobody is left to steer has no keyboard, so mux waited in poll forever
607 # with the exit code in hand (RC=124, measured). The rule is the same rule
608 # with its second half stated: an ending nobody can leave is mux's ending.
609 #
610 # The gap is built rather than raced: the shell reads `sleep 4` and
611 # `exit 3` from a stdin that closes IMMEDIATELY after, so the pipe is long
612 # gone by the time the session ends four seconds later. `timeout` is what
613 # turns a regression into a failure instead of a wedged suite.
614 XETIME_START=$(date +%s)
615 set +e
616 { sleep 1; printf '\034c'; sleep 3; printf 'sleep 4\n'; printf 'exit 3\n'; } | \
617 XDG_STATE_HOME="$XESTATE" timeout 40 "$MUX" --sock "$SOCK47" \
618 > "$OUT.xe2" 2> "$OUT.xe2.err"
619 RC=$?
620 set -e
621 XETIME=$(( $(date +%s) - XETIME_START ))
622 [ "$RC" -eq 3 ] || {
623 echo "e2e FAIL: exit semantics: a piped two-tile client whose session ended"
624 echo " exited $RC after ${XETIME}s, want 3 — 124 is the wall it"
625 echo " left to a neighbour with nobody left to steer it:"
626 cat "$OUT.xe2.err"; exit 1; }
627 # Promptly, not eventually: the code is right in both readings, but a
628 # client that only exits because the timeout reaped it is the bug wearing
629 # the right answer. The session ends ~8s in; 20 is generous and still far
630 # under the 40s timeout that would otherwise be the only bound.
631 [ "$XETIME" -lt 20 ] || {
632 echo "e2e FAIL: exit semantics: the piped client took ${XETIME}s to notice its"
633 echo " own session had ended — it exited, but it did not notice"; exit 1; }
634 assert_stopped "$SOCK47" "$D47PID" "exit semantics" "$OUT.xestop"
635 D47PID=""
636 ok "a session ending in the focused tile exits mux alone, and leaves the focus to a neighbour"