a73x

0761be0b

test: 12_panes moves to a file of its own

a73x   2026-08-26 19:13

Commit message
test: 12_panes moves to a file of its own

panes: the cursor, fullscreen, resize, and the layout sidecar — 10 scenarios, 804 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -102,7 +102,7 @@ done
102 E2E_DIR=$(dirname "$0") 102 E2E_DIR=$(dirname "$0")
103 . "$E2E_DIR/e2e_lib.sh" 103 . "$E2E_DIR/e2e_lib.sh"
104 104
105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select' 105 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'
106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses 106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses
107 # these by name rather than running them: a group whose fixtures are 107 # these by name rather than running them: a group whose fixtures are
108 # missing does not fail, it passes having tested something else — and a 108 # missing does not fail, it passes having tested something else — and a
@@ -145,807 +145,6 @@ for _g in $E2E_GROUPS; do
145 . "$E2E_DIR/e2e_$_g.sh" 145 . "$E2E_DIR/e2e_$_g.sh"
146 done 146 done
147 147
148 # The scrollback-rect leg: a focused tile's history page must own only its
149 # sub-rect, so its daemon gets a socket of its own for the grid-size
150 # reason every wall leg has.
151 SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock"
152 defer_sock "$SOCK53"
153 SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock"
154 defer_sock "$SOCK54"
155 SOCK55="${TMPDIR:-/tmp}/muxd-e2e-resize-$$.sock"
156 defer_sock "$SOCK55"
157 SOCK56="${TMPDIR:-/tmp}/muxd-e2e-spanclear-$$.sock"
158 defer_sock "$SOCK56"
159 SOCK57="${TMPDIR:-/tmp}/muxd-e2e-hjkl-$$.sock"
160 defer_sock "$SOCK57"
161 SOCK58="${TMPDIR:-/tmp}/muxd-e2e-splitbirth-$$.sock"
162 defer_sock "$SOCK58"
163 SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock"
164 defer_sock "$SOCK59"
165 SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock"
166 defer_sock "$SOCK60"
167 SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock"
168 defer_sock "$SOCK61"
169 SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock"
170 defer_sock "$SOCK62"
171 SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock"
172 defer_sock "$SOCK63"
173
174 # --- the cursor rests in the focused tile, whoever painted last ----------
175 # The cursor-ownership leg: a daemon of its own, for the grid-size reason
176 # every wall leg has — the assertion is about where the cursor RESTS, and a
177 # session another block created would change the tile it rests in.
178 SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock"
179 defer_sock "$SOCK52"
180 #
181 # Every painter ends by showing the cursor where its own stripe sits, so
182 # without an owner the visible cursor lands on whichever pump painted last:
183 # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live
184 # while the keys go elsewhere. The focused tile's paint now records its
185 # screen cursor in Shared, and an unfocused paint's last act is to put the
186 # cursor back there. This leg focuses tile 3 of a three-tile wall and
187 # asserts the terminal's final cursor position is in tile 3's stripe —
188 # deterministic, because every paint now ends at the focused tile's cursor.
189 #
190 # Three sessions on a daemon of its own, each marked by its shell before the
191 # wall attaches (the shell-expanded marker trick: a hit is the shell's work,
192 # never an echo of anything typed here). The ptyclient wall focuses tile 3,
193 # settles, and detaches; the capture's last cursor-position escape is the
194 # witness.
195 start_daemon "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound" --shell /bin/sh
196 D52PID=$DPID
197
198 pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a
199 pipe_send 'printf "ma-%%s\\n" pin\n'
200 await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client"
201 pipe_detach
202 wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a
203 pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b
204 pipe_send 'printf "mb-%%s\\n" pin\n'
205 await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client"
206 pipe_detach
207 wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b
208 pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c
209 pipe_send 'printf "mc-%%s\\n" pin\n'
210 await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client"
211 pipe_detach
212 wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c
213
214 set +e
215 timeout 40 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \
216 "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF'
217 expect mc-pin 20000
218 settle 500 15000
219 send \x1c3
220 settle 500 15000
221 send \x1cd
222 waitexit 10000
223 EOF
224 RC=$?
225 set -e
226 [ "$RC" -eq 0 ] || {
227 echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:"
228 cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; }
229 # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21).
230 # The focused tile's paint records its cursor; an unfocused paint's last act
231 # is a bare CUP putting it back there, with no show of its own. So the
232 # terminal's final cursor position — the last CUP in the capture, show or
233 # none — rests in tile 3's stripe whichever pump painted last. The last
234 # CUP+show would be the last painter's OWN cursor (racy); the last CUP of
235 # any kind is the focused tile's, and that is what the fix owns.
236 _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H'
237 _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1)
238 # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix.
239 _cur_row=${_last_cup#??}
240 _cur_row=${_cur_row%%;*}
241 [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || {
242 echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):"
243 cat "$OUT.cucap"; exit 1; }
244 assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop"
245 D52PID=""
246 ok "the cursor rests in the focused tile, whichever pump painted last"
247
248 # ---- a focused tile's scrollback page owns only its sub-rect ----------
249 #
250 # The one-tile wheel leg above has no label bar, so its geometry is the
251 # plain client's: the one paint at row 0 that DOES own the whole screen.
252 # That exemption is what let a scrollback page at a row_off through that
253 # opened with `\x1b[H\x1b[2J` — clearing the whole terminal and painting
254 # history at row 1, while the exit path repaints only the focused tile's
255 # own rect, so every neighbour stayed blank. This leg is the twin that
256 # catches it: two tiles over 30 rows, the focused one scrolls back on the
257 # wheel, and the neighbour's pin must still be the last content painted in
258 # its rows, with no whole-screen clear following it.
259 #
260 # Two sessions on one daemon. The focused tile (a, the first spelling)
261 # fills a scrollback the wheel can page into; the neighbour (b) holds a
262 # pin and is never touched again. Both shells are /bin/sh and the content
263 # is typed, the focus-skip leg's pattern — the neighbour's pin is the
264 # session's own output, not an echo of the command that made it.
265 start_daemon "$SOCK53" "$OUT.sb2.d" "scrollback-rect daemon never bound" --shell /bin/sh
266 D54PID=$DPID
267
268 # Session a: 100 lines of history, then cat. The wall attaches only once
269 # seq has finished, so the page the wheel fetches is content this tile was
270 # never sent live — the wheel leg's reason, verbatim.
271 pipe_mux "$OUT.sb2a" "$OUT.sb2a.err" timeout 40 "$MUX" --sock "$SOCK53" --session a
272 pipe_send 'seq 1 100 | sed '\''s/^/wln/'\''; exec cat\n'
273 await_out "$OUT.sb2a" "wln100" "scrollback-rect: session a never finished seq"
274 pipe_detach
275 wait_grid "$SOCK53" "wln100" "scrollback-rect: session a's tail" a
276
277 # Session b: a pin the capture can grep for, then cat. `printf "sb2%s\n"
278 # nbr` so the pin is the output, not the echoed command — the focus-skip
279 # leg's prefix trick.
280 pipe_mux "$OUT.sb2b" "$OUT.sb2b.err" timeout 40 "$MUX" --sock "$SOCK53" --session b
281 pipe_send 'printf "sb2%%s\\n" nbr; exec cat\n'
282 await_out "$OUT.sb2b" "sb2nbr" "scrollback-rect: session b's pin never reached the client"
283 pipe_detach
284 wait_grid "$SOCK53" "sb2nbr" "scrollback-rect: session b's pin" b
285
286 set +e
287 timeout 90 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.sb2cap" --err "$OUT.sb2cap.err" -- \
288 "$MUX" wall "--sock $SOCK53#a" "--sock $SOCK53#b" > "$OUT.sb2pc" 2>&1 <<'EOF'
289 expect sb2nbr 20000
290 settle 700 20000
291 send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M
292 expect \x1b[7m[scroll] 15000
293 settle 500 15000
294 send \x1cd
295 waitexit 10000
296 EOF
297 RC=$?
298 set -e
299 [ "$RC" -eq 0 ] || {
300 echo "e2e FAIL: scrollback-rect: ptyclient leg exited $RC (did the focused wheel scroll?):"
301 cat "$OUT.sb2pc"; exit 1; }
302 # The neighbour's pin is still the last content painted in its rows: the
303 # capture holds it, and nothing after it cleared the whole screen. A
304 # scrollback page that owned the whole terminal (the bug) would open with
305 # `\x1b[H\x1b[2J` right here, wiping the neighbour's rows; the fix clears
306 # per row inside the focused tile's sub-rect only.
307 grep -qaF "sb2nbr" "$OUT.sb2cap" || {
308 echo "e2e FAIL: scrollback-rect: the neighbour's pin left the capture:"
309 cat "$OUT.sb2pc"; exit 1; }
310 _sb2last=$(grep -aboF -- "sb2nbr" "$OUT.sb2cap" | tail -1 | cut -d: -f1)
311 tail -c +$((_sb2last + 1)) "$OUT.sb2cap" | grep -aF -- "$(printf '\x1b[2J')" && {
312 echo "e2e FAIL: scrollback-rect: a whole-screen clear followed the neighbour's paint:"
313 cat "$OUT.sb2pc"; exit 1; }
314 assert_stopped "$SOCK53" "$D54PID" "scrollback-rect" "$OUT.sb2stop"
315 D54PID=""
316 ok "a focused tile's scrollback page owns only its sub-rect, and the neighbour's pin stays"
317
318 # ---- fullscreen: f toggles, focus follows, restore -------------------
319 #
320 # `f` is a layout op, not a mode: the focused tile takes the whole
321 # terminal, every other gets 0x0. A 0x0 rect claims nothing, so hidden
322 # panes stay attached and paint nothing. Focus moves while fullscreened
323 # re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores
324 # the real layout. This leg proves all three with `muxa capture` — the
325 # marker each session received names which tile owned the terminal.
326 #
327 # No convergence point: `muxa capture` reads the daemon's grid, not the
328 # terminal's, and the daemon's grid is the side channel this asserts on.
329 start_daemon "$SOCK54" "$OUT.fs.d" "fullscreen daemon never bound" --shell /bin/sh
330 D55PID=$DPID
331
332 pipe_mux "$OUT.fsa" "$OUT.fsa.err" timeout 40 "$MUX" --sock "$SOCK54" --session a
333 pipe_send 'printf "fs-%%s\\n" init-a\n'
334 await_out "$OUT.fsa" "fs-init-a" "fullscreen: session a's marker never reached the client"
335 pipe_detach
336 wait_grid "$SOCK54" "fs-init-a" "fullscreen: session a's marker" a
337 pipe_mux "$OUT.fsb" "$OUT.fsb.err" timeout 40 "$MUX" --sock "$SOCK54" --session b
338 pipe_send 'printf "fs-%%s\\n" init-b\n'
339 await_out "$OUT.fsb" "fs-init-b" "fullscreen: session b's marker never reached the client"
340 pipe_detach
341 wait_grid "$SOCK54" "fs-init-b" "fullscreen: session b's marker" b
342
343 set +e
344 # wide: 80x24 trips the aspect rule (80 >= 48 → beside)
345 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.fscap" --err "$OUT.fscap.err" -- \
346 "$MUX" wall "--sock $SOCK54#a" "--sock $SOCK54#b" > "$OUT.fspc" 2>&1 <<'EOF'
347 expect fs-init-b 20000
348 settle 700 20000
349 send \x1cf
350 settle 700 20000
351 send printf 'fs-%s\n' one\n
352 expect fs-one 10000
353 settle 400 15000
354 send \x1c2
355 settle 700 20000
356 send printf 'fs-%s\n' two\n
357 expect fs-two 10000
358 settle 400 15000
359 send \x1cf
360 settle 700 20000
361 send \x1cd
362 waitexit 10000
363 EOF
364 RC=$?
365 set -e
366 [ "$RC" -eq 0 ] || {
367 echo "e2e FAIL: fullscreen: ptyclient leg exited $RC:"
368 cat "$OUT.fspc" "$OUT.fscap.err"; exit 1; }
369 # fs-one landed in session a (the tile that was fullscreened first);
370 # fs-two landed in session b (the tile Ctrl-\ 2 moved the full rect to).
371 timeout 20 "$MUXA" capture --sock "$SOCK54" --session a > "$OUT.fsfa" 2>&1
372 timeout 20 "$MUXA" capture --sock "$SOCK54" --session b > "$OUT.fsfb" 2>&1
373 grep -q "fs-one" "$OUT.fsfa" || {
374 echo "e2e FAIL: fullscreen: session a never got fs-one:"
375 cat "$OUT.fsfa"; exit 1; }
376 grep -q "fs-one" "$OUT.fsfb" && {
377 echo "e2e FAIL: fullscreen: fs-one reached session b, which was hidden:"
378 cat "$OUT.fsfb"; exit 1; }
379 grep -q "fs-two" "$OUT.fsfb" || {
380 echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not move the full rect to session b:"
381 cat "$OUT.fsfb"; exit 1; }
382 grep -q "fs-two" "$OUT.fsfa" && {
383 echo "e2e FAIL: fullscreen: fs-two reached session a, which was hidden after the focus move:"
384 cat "$OUT.fsfa"; exit 1; }
385 # What the human saw after Ctrl-\ 2: the `>` moved and the digits did not.
386 # A bar's digit is the chord that reaches that tile for its whole life, not
387 # a count of who holds the focus — the render oracle is the only witness,
388 # since the daemon's grid has no bars in it. The label is cut to the stripe
389 # at 80 columns beside, so the marker and the head of the spelling are what
390 # survive; the digit is what tells the two stripes apart.
391 "$RENDER" --cols 80 --rows 24 < "$OUT.fscap" > "$OUT.fsgrid" || {
392 echo "e2e FAIL: fullscreen: render oracle failed"
393 cat "$OUT.fsgrid"; exit 1; }
394 grep -q -- " 2> --sock" "$OUT.fsgrid" || {
395 echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not leave the \`>\` on tile 2's digit:"
396 cat "$OUT.fsgrid"; exit 1; }
397 grep -q -- " 1 --sock" "$OUT.fsgrid" || {
398 echo "e2e FAIL: fullscreen: tile 1 lost its digit or kept the \`>\`:"
399 cat "$OUT.fsgrid"; exit 1; }
400 assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop"
401 D55PID=""
402 ok "fullscreen gives the focused tile the terminal; focus follows; f restores"
403
404 # ---- Ctrl-\ r trades cells between panes -----------------------------
405 #
406 # Two beside panes at 80x24 (aspect rule → beside). Ctrl-\ r enters
407 # resize mode; lll grows the focused pane's width by 3 cells, moving the
408 # rail 3 columns right. The rail is a reverse-video space painted at a
409 # CUP column; grep the column before and after. Esc exits resize mode,
410 # and typed prose lands in the focused session (marker echo).
411 #
412 # No convergence point: the rail column is a terminal-capture assertion,
413 # not a daemon-grid comparison.
414 start_daemon "$SOCK55" "$OUT.rsz.d" "resize daemon never bound" --shell /bin/sh
415 D56PID=$DPID
416
417 pipe_mux "$OUT.rsza" "$OUT.rsza.err" timeout 40 "$MUX" --sock "$SOCK55" --session a
418 pipe_send 'printf "rsz-%%s\\n" init-a\n'
419 await_out "$OUT.rsza" "rsz-init-a" "resize: session a's marker never reached the client"
420 pipe_detach
421 wait_grid "$SOCK55" "rsz-init-a" "resize: session a's marker" a
422 pipe_mux "$OUT.rszb" "$OUT.rszb.err" timeout 40 "$MUX" --sock "$SOCK55" --session b
423 pipe_send 'printf "rsz-%%s\\n" init-b\n'
424 await_out "$OUT.rszb" "rsz-init-b" "resize: session b's marker never reached the client"
425 pipe_detach
426 wait_grid "$SOCK55" "rsz-init-b" "resize: session b's marker" b
427
428 set +e
429 # 80x24: aspect rule makes beside; two panes split 39/40 with the rail
430 # at column 40 (1-indexed). Three l presses grow the left pane by 3,
431 # moving the rail to column 43.
432 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.rszcap" --err "$OUT.rszcap.err" -- \
433 "$MUX" wall "--sock $SOCK55#a" "--sock $SOCK55#b" > "$OUT.rszpc" 2>&1 <<'EOF'
434 expect rsz-init-b 20000
435 settle 700 20000
436 send \x1cr
437 settle 300 10000
438 send l
439 settle 300 10000
440 send l
441 settle 300 10000
442 send l
443 settle 500 15000
444 send \x1b
445 settle 300 10000
446 send printf 'rsz-%s\n' grown\n
447 expect rsz-grown 10000
448 settle 400 15000
449 send \x1cd
450 waitexit 10000
451 EOF
452 RC=$?
453 set -e
454 [ "$RC" -eq 0 ] || {
455 echo "e2e FAIL: resize: ptyclient leg exited $RC:"
456 cat "$OUT.rszpc" "$OUT.rszcap.err"; exit 1; }
457 # The rail paints as ESC[row;colH followed by ESC[7m (reverse video).
458 # Extract the column from every CUP that precedes a reverse-video
459 # space — the rail's column — and verify it moved right by 3.
460 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
461 # Before resize: rail at column 40 (1-indexed). After lll: column 43.
462 # Grab all rail CUP columns; the last one is the post-resize position.
463 _rail_cols=$(grep -ao "$_rail_re" "$OUT.rszcap" | \
464 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n)
465 _last_rail=$(echo "$_rail_cols" | tail -1)
466 [ -n "$_last_rail" ] || {
467 echo "e2e FAIL: resize: no rail glyph found in the capture:"
468 cat "$OUT.rszcap"; exit 1; }
469 # 39 cols + rail at 40 initially; 42 cols + rail at 43 after lll.
470 [ "$_last_rail" -ge 43 ] || {
471 echo "e2e FAIL: resize: rail column is $_last_rail, want >=43 (40 + 3 grows):"
472 cat "$OUT.rszcap"; exit 1; }
473 # Esc ended resize mode and typed prose reached the focused session.
474 timeout 20 "$MUXA" capture --sock "$SOCK55" --session a > "$OUT.rszsta" 2>&1
475 timeout 20 "$MUXA" capture --sock "$SOCK55" --session b > "$OUT.rszstb" 2>&1
476 if ! grep -q "rsz-grown" "$OUT.rszsta" && ! grep -q "rsz-grown" "$OUT.rszstb"; then
477 echo "e2e FAIL: resize: rsz-grown reached neither session after Esc:"
478 cat "$OUT.rszsta" "$OUT.rszstb"; exit 1
479 fi
480 assert_stopped "$SOCK55" "$D56PID" "resize" "$OUT.rszstop"
481 D56PID=""
482 ok "Ctrl-\\ r trades cells between panes; Esc returns to prose"
483
484 # ---- span-clear survival: the engine oracle -------------------------
485 #
486 # Two beside panes at 80x24 (aspect rule: cols >= 2*rows → beside). A
487 # marker in the RIGHT pane's session, then a flood in the LEFT pane's
488 # session (seq 1 200). The regression this catches forever is a whole-line
489 # clear (\x1b[2K) blanking the neighbor: a painter that emits too much
490 # still passes a byte grep, so the assertion replays the wall client's
491 # FULL captured stdout through the render fixture (an engine, not a grep)
492 # and checks the right pane's marker text survives on the replayed grid.
493 start_daemon "$SOCK56" "$OUT.spc.d" "span-clear daemon never bound" --shell /bin/sh
494 D57PID=$DPID
495
496 pipe_mux "$OUT.spca" "$OUT.spca.err" timeout 40 "$MUX" --sock "$SOCK56" --session a
497 pipe_send 'printf "spc-%%s\\n" left-flood\n'
498 await_out "$OUT.spca" "spc-left-flood" "span-clear: left session's marker never reached the client"
499 pipe_detach
500 wait_grid "$SOCK56" "spc-left-flood" "span-clear: left session's marker" a
501 pipe_mux "$OUT.spcb" "$OUT.spcb.err" timeout 40 "$MUX" --sock "$SOCK56" --session b
502 pipe_send 'printf "spc-%%s\\n" right-survivor\n'
503 await_out "$OUT.spcb" "spc-right-survivor" "span-clear: right session's marker never reached the client"
504 pipe_detach
505 wait_grid "$SOCK56" "spc-right-survivor" "span-clear: right session's marker" b
506
507 set +e
508 # 80x24: aspect rule → beside. The wall client is a tty session: its
509 # stdout is the terminal's full byte stream, captured by ptyclient.
510 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.spccap" --err "$OUT.spccap.err" -- \
511 "$MUX" wall "--sock $SOCK56#a" "--sock $SOCK56#b" > "$OUT.spcpc" 2>&1 <<'EOF'
512 expect spc-right-survivor 20000
513 settle 700 20000
514 send \x1ch
515 settle 500 15000
516 send seq 1 200\n
517 expect 200 20000
518 settle 700 20000
519 send \x1cd
520 waitexit 10000
521 EOF
522 RC=$?
523 set -e
524 [ "$RC" -eq 0 ] || {
525 echo "e2e FAIL: span-clear: ptyclient leg exited $RC:"
526 cat "$OUT.spcpc" "$OUT.spccap.err"; exit 1; }
527 # The oracle: replay the captured stream through the render fixture at the
528 # same dimensions, producing the final grid text. The right pane's marker
529 # must still be on that grid — a span-clear regression blanks it.
530 "$RENDER" --cols 80 --rows 24 < "$OUT.spccap" > "$OUT.spcgrid" || {
531 echo "e2e FAIL: span-clear: render fixture failed on the capture:"
532 cat "$OUT.spcgrid"; exit 1; }
533 grep -q "spc-right-survivor" "$OUT.spcgrid" || {
534 echo "e2e FAIL: span-clear: right pane's marker lost from the replayed grid:"
535 echo " a span-clear regression blanked the neighbor:"
536 cat "$OUT.spcgrid"; exit 1; }
537 assert_stopped "$SOCK56" "$D57PID" "span-clear" "$OUT.spcstop"
538 D57PID=""
539 ok "a span-clear flood in the left pane cannot blank the right pane's grid"
540
541 # ---- hjkl focus walk -------------------------------------------------
542 #
543 # One tile, split right (births a second session), split below (births a
544 # third). Then walk focus with hjkl and type a distinct marker after each
545 # move; muxa capture names which session received it. Covers at least a
546 # left move, a right move, and one vertical move.
547 #
548 # No convergence point: the assertion is per-session muxa capture, not a
549 # daemon-grid diff.
550 start_daemon "$SOCK57" "$OUT.hjk.d" "hjkl daemon never bound" --shell /bin/sh
551 D58PID=$DPID
552
553 pipe_mux "$OUT.hjkcap" "$OUT.hjkcap.err" timeout 40 "$MUX" --sock "$SOCK57" --session a
554 pipe_send 'printf "hjk-%%s\\n" origin\n'
555 await_out "$OUT.hjkcap" "hjk-origin" "hjkl: origin marker never reached the client"
556 pipe_detach
557 wait_grid "$SOCK57" "hjk-origin" "hjkl: origin marker" a
558
559 set +e
560 # 80x24: aspect rule → beside for the first split; the second split goes
561 # below, producing a 1 | (2 over 3) tree.
562 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.hjkcap" --err "$OUT.hjkcap.err" -- \
563 "$MUX" wall "--sock $SOCK57#a" > "$OUT.hjkpc" 2>&1 <<'EOF'
564 expect hjk-origin 20000
565 settle 700 20000
566 send \x1c|
567 settle 1000 20000
568 send \x1c-
569 settle 1000 20000
570 send \x1ch
571 settle 500 15000
572 send printf 'hjk-%s\n' went-left\n
573 expect hjk-went-left 10000
574 settle 500 15000
575 send \x1cl
576 settle 500 15000
577 send printf 'hjk-%s\n' went-right\n
578 expect hjk-went-right 10000
579 settle 500 15000
580 send \x1cj
581 settle 500 15000
582 send printf 'hjk-%s\n' went-down\n
583 expect hjk-went-down 10000
584 settle 500 15000
585 send \x1cd
586 waitexit 10000
587 EOF
588 RC=$?
589 set -e
590 [ "$RC" -eq 0 ] || {
591 echo "e2e FAIL: hjkl: ptyclient leg exited $RC:"
592 cat "$OUT.hjkpc" "$OUT.hjkcap.err"; exit 1; }
593 # Three sessions exist now: a (the origin), and two birthed by splits.
594 # The daemon assigns numeric names to the new sessions; capture all three.
595 timeout 20 "$MUXA" capture --sock "$SOCK57" --session a > "$OUT.hjkfa" 2>&1
596 timeout 20 "$MUXA" capture --sock "$SOCK57" --session 1 > "$OUT.hjkfb" 2>&1
597 timeout 20 "$MUXA" capture --sock "$SOCK57" --session 2 > "$OUT.hjkfc" 2>&1
598 # went-left: typed after \x1ch from the right pane → lands in session a.
599 grep -q "hjk-went-left" "$OUT.hjkfa" || {
600 echo "e2e FAIL: hjkl: hjk-went-left not in session a (left focus move):"
601 cat "$OUT.hjkfa"; exit 1; }
602 # went-right: typed after \x1cl from session a → lands in session 1.
603 grep -q "hjk-went-right" "$OUT.hjkfb" || {
604 echo "e2e FAIL: hjkl: hjk-went-right not in session 1 (right focus move):"
605 cat "$OUT.hjkfb"; exit 1; }
606 # went-down: typed after \x1cj from session 1 → lands in session 2.
607 grep -q "hjk-went-down" "$OUT.hjkfc" || {
608 echo "e2e FAIL: hjkl: hjk-went-down not in session 2 (down focus move):"
609 cat "$OUT.hjkfc"; exit 1; }
610 assert_stopped "$SOCK57" "$D58PID" "hjkl" "$OUT.hjkstop"
611 D58PID=""
612 ok "hjkl walks focus across panes in both axes"
613
614 # ---- split births a session beside -----------------------------------
615 #
616 # From one pane, Ctrl-\ | splits right and births a new session. The
617 # daemon's session count rises to two, and the new pane's label bar sits
618 # at a column to the right of the rail (the rail paints as ESC[r;cH
619 # ESC[7m — the same idiom the resize leg uses).
620 start_daemon "$SOCK58" "$OUT.spl.d" "split-birth daemon never bound" --shell /bin/sh
621 D59PID=$DPID
622
623 pipe_mux "$OUT.splcap" "$OUT.splcap.err" timeout 40 "$MUX" --sock "$SOCK58" --session a
624 pipe_send 'printf "spl-%%s\\n" origin\n'
625 await_out "$OUT.splcap" "spl-origin" "split-birth: origin marker never reached the client"
626 pipe_detach
627 wait_grid "$SOCK58" "spl-origin" "split-birth: origin marker" a
628
629 set +e
630 # 80x24: aspect rule → beside. One tile starts, then Ctrl-\ | births a
631 # second session to the right.
632 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.splcap" --err "$OUT.splcap.err" -- \
633 "$MUX" wall "--sock $SOCK58#a" > "$OUT.splpc" 2>&1 <<'EOF'
634 expect spl-origin 20000
635 settle 700 20000
636 send \x1c|
637 settle 1500 20000
638 send \x1cd
639 waitexit 10000
640 EOF
641 RC=$?
642 set -e
643 [ "$RC" -eq 0 ] || {
644 echo "e2e FAIL: split-birth: ptyclient leg exited $RC:"
645 cat "$OUT.splpc" "$OUT.splcap.err"; exit 1; }
646 # The daemon held sessions 0 (default) and a; the split birthed one
647 # more, so three live sessions proves the chord created a new session.
648 wait_sessions "$SOCK58" 3 "split-birth: split did not birth a new session"
649 # The rail paints as ESC[row;colH ESC[7m (reverse video). The new pane's
650 # label bar is at a column greater than the rail's column — it sits to
651 # the right of the separator, which is the whole point of beside.
652 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
653 _rail_col=$(grep -ao "$_rail_re" "$OUT.splcap" | \
654 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | head -1)
655 [ -n "$_rail_col" ] || {
656 echo "e2e FAIL: split-birth: no rail glyph found in the capture:"
657 cat "$OUT.splcap"; exit 1; }
658 # The rail sits at the boundary; the new pane's label bar is beyond it.
659 # A label bar is a CUP followed by text, not reverse-video. Find a CUP
660 # whose column exceeds the rail's — that is the right pane's bar start.
661 _bar_col=$(grep -ao $'\x1b\\[[0-9]*;[0-9]*H' "$OUT.splcap" | \
662 sed 's/\x1b\[[0-9]*;\([0-9]*\)H/\1/' | sort -n | \
663 awk -v r="$_rail_col" '$1 > r { print; exit }')
664 [ -n "$_bar_col" ] || {
665 echo "e2e FAIL: split-birth: no label bar found right of the rail (col $_rail_col):"
666 cat "$OUT.splcap"; exit 1; }
667 assert_stopped "$SOCK58" "$D59PID" "split-birth" "$OUT.splstop"
668 D59PID=""
669 ok "Ctrl-\\ | births a session beside; its label bar sits right of the rail"
670
671 # ---- layout sidecar: restore round-trip ------------------------------
672 #
673 # A no-argv `mux wall` hydrates from the wall file, and on Ctrl-\ d the
674 # resized tree is saved to the layout sidecar. Reattaching with the same
675 # state home loads the sidecar verbatim — the resize survived the round
676 # trip. The rail's CUP column in the second capture is >= 43 (three
677 # right-resizes from a 40|40 split), and a marker typed into the restored
678 # wall lands in the focused session.
679 LPSTATE="${TMPDIR:-/tmp}/mux-e2e-lprrestore-state-$$"
680 defer_rm "$LPSTATE"
681 LPWALL="$LPSTATE/mux/wall"
682 LPLAYOUT="$LPSTATE/mux/layout"
683 start_daemon "$SOCK59" "$OUT.lpr.d" "layout-restore daemon never bound" --shell /bin/sh
684 D60PID=$DPID
685
686 pipe_mux "$OUT.lpra" "$OUT.lpra.err" timeout 40 "$MUX" --sock "$SOCK59" --session a
687 pipe_send 'printf "lpr-%%s\\n" origin-a\n'
688 await_out "$OUT.lpra" "lpr-origin-a" "layout-restore: session a marker never reached the client"
689 pipe_detach
690 wait_grid "$SOCK59" "lpr-origin-a" "layout-restore: session a marker" a
691 pipe_mux "$OUT.lprb" "$OUT.lprb.err" timeout 40 "$MUX" --sock "$SOCK59" --session b
692 pipe_send 'printf "lpr-%%s\\n" origin-b\n'
693 await_out "$OUT.lprb" "lpr-origin-b" "layout-restore: session b marker never reached the client"
694 pipe_detach
695 wait_grid "$SOCK59" "lpr-origin-b" "layout-restore: session b marker" b
696
697 # Write the wall file by hand: two spellings, one per line. The no-argv
698 # `mux wall` reads this on startup — the hydration path.
699 mkdir -p "$LPSTATE/mux"
700 printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK59" "$SOCK59" > "$LPWALL"
701
702 set +e
703 # First ptyclient: hydrate, resize three right, detach. The detach saves
704 # the sidecar.
705 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
706 --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \
707 "$MUX" wall > "$OUT.lprpc1" 2>&1 <<'EOF'
708 expect lpr-origin-a 20000
709 settle 700 20000
710 send \x1cr
711 settle 300 5000
712 send l
713 settle 300 5000
714 send l
715 settle 300 5000
716 send l
717 settle 500 5000
718 send \x1b
719 settle 500 5000
720 send \x1cl
721 settle 500 5000
722 send \x1cd
723 waitexit 10000
724 EOF
725 RC=$?
726 set -e
727 [ "$RC" -eq 0 ] || {
728 echo "e2e FAIL: layout-restore: first ptyclient exited $RC:"
729 cat "$OUT.lprpc1" "$OUT.lprcap1.err"; exit 1; }
730
731 # The sidecar exists and its header is the format the spec mandates.
732 [ -f "$LPLAYOUT" ] || {
733 echo "e2e FAIL: layout-restore: sidecar not saved at $LPLAYOUT"
734 ls -la "$LPSTATE/mux/" 2>&1; exit 1; }
735 _head1=$(head -1 "$LPLAYOUT")
736 _head2=$(sed -n '2p' "$LPLAYOUT")
737 [ "$_head1" = "mux-layout 1" ] || {
738 echo "e2e FAIL: layout-restore: sidecar header is '$_head1', want 'mux-layout 1'"
739 cat "$LPLAYOUT"; exit 1; }
740 # A 2-pane beside at 80 cols: the root is `beside 0`.
741 case "$_head2" in
742 "beside 0") ;;
743 *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'"
744 cat "$LPLAYOUT"; exit 1 ;;
745 esac
746 # The focus record: focus-right moved focus to pane b (leaf 1).
747 grep -q '^focus 1$' "$LPLAYOUT" || {
748 echo "e2e FAIL: layout-restore: sidecar carries no focus record"
749 cat "$LPLAYOUT"; exit 1; }
750
751 set +e
752 # Second ptyclient: reattach. The sidecar restores the resized tree —
753 # the rail sits at column >= 43. Type a marker, then detach.
754 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
755 --out "$OUT.lprcap2" --err "$OUT.lprcap2.err" -- \
756 "$MUX" wall > "$OUT.lprpc2" 2>&1 <<'EOF'
757 expect lpr-origin-a 20000
758 settle 1000 20000
759 send printf 'lpr-rt-%s\n' marker\n
760 expect lpr-rt-marker 10000
761 settle 500 15000
762 send \x1cd
763 waitexit 10000
764 EOF
765 RC=$?
766 set -e
767 [ "$RC" -eq 0 ] || {
768 echo "e2e FAIL: layout-restore: second ptyclient exited $RC:"
769 cat "$OUT.lprpc2" "$OUT.lprcap2.err"; exit 1; }
770 # The rail's CUP column in the second capture must be >= 43 — three
771 # right-resizes moved the boundary from 41 to 44. The _rail_re idiom
772 # (from the split-birth leg) matches ESC[row;colH ESC[7m.
773 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
774 _max_rail=$(grep -ao "$_rail_re" "$OUT.lprcap2" | \
775 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | tail -1)
776 [ -n "$_max_rail" ] || {
777 echo "e2e FAIL: layout-restore: no rail glyph in the second capture:"
778 cat "$OUT.lprcap2"; exit 1; }
779 [ "$_max_rail" -ge 43 ] || {
780 echo "e2e FAIL: layout-restore: rail column $_max_rail < 43 (resize did not survive)"
781 cat "$OUT.lprcap2"; exit 1; }
782 # The marker typed into the restored wall landed in session b — the
783 # focus was saved on pane b and restore put it back there, not tile 0.
784 timeout 20 "$MUXA" capture --sock "$SOCK59" --session b > "$OUT.lprfb" 2>&1
785 grep -q "lpr-rt-marker" "$OUT.lprfb" || {
786 echo "e2e FAIL: layout-restore: marker not in session b (focus did not survive):"
787 cat "$OUT.lprfb"; exit 1; }
788 timeout 20 "$MUXA" capture --sock "$SOCK59" --session a > "$OUT.lprfa" 2>&1
789 ! grep -q "lpr-rt-marker" "$OUT.lprfa" || {
790 echo "e2e FAIL: layout-restore: marker leaked into session a (focus was tile 0, not b):"
791 cat "$OUT.lprfa"; exit 1; }
792 assert_stopped "$SOCK59" "$D60PID" "layout-restore" "$OUT.lprstop"
793 D60PID=""
794 rm -rf "$LPSTATE"
795 ok "a resized layout survives a detach/reattach round trip via the sidecar"
796
797 # ---- layout sidecar: healing on wall-file drift ----------------------
798 #
799 # After a 2-pane sidecar is saved, the wall file drifts: one original is
800 # removed and a new spelling is added. On reattach, restoreLayout heals —
801 # the survivor leaf keeps its session, the new tile inserts beside it,
802 # and the unmatched saved leaf collapses out. Two tiles, not three, and
803 # the wall did not refuse.
804 LPHSTATE="${TMPDIR:-/tmp}/mux-e2e-lpheal-state-$$"
805 defer_rm "$LPHSTATE"
806 LPHWALL="$LPHSTATE/mux/wall"
807 LPHLAYOUT="$LPHSTATE/mux/layout"
808 start_daemon "$SOCK60" "$OUT.lph.d" "layout-heal daemon never bound" --shell /bin/sh
809 D61PID=$DPID
810
811 pipe_mux "$OUT.lpha" "$OUT.lpha.err" timeout 40 "$MUX" --sock "$SOCK60" --session a
812 pipe_send 'printf "lph-%%s\\n" survivor-a\n'
813 await_out "$OUT.lpha" "lph-survivor-a" "layout-heal: session a marker never reached the client"
814 pipe_detach
815 wait_grid "$SOCK60" "lph-survivor-a" "layout-heal: session a marker" a
816 pipe_mux "$OUT.lphb" "$OUT.lphb.err" timeout 40 "$MUX" --sock "$SOCK60" --session b
817 pipe_send 'printf "lph-%%s\\n" removed-b\n'
818 await_out "$OUT.lphb" "lph-removed-b" "layout-heal: session b marker never reached the client"
819 pipe_detach
820 wait_grid "$SOCK60" "lph-removed-b" "layout-heal: session b marker" b
821 pipe_mux "$OUT.lphc" "$OUT.lphc.err" timeout 40 "$MUX" --sock "$SOCK60" --session c
822 pipe_send 'printf "lph-%%s\\n" newcomer-c\n'
823 await_out "$OUT.lphc" "lph-newcomer-c" "layout-heal: session c marker never reached the client"
824 pipe_detach
825 wait_grid "$SOCK60" "lph-newcomer-c" "layout-heal: session c marker" c
826
827 # Wall file starts with a and b. The first ptyclient detaches and saves
828 # a 2-pane sidecar for [a, b].
829 mkdir -p "$LPHSTATE/mux"
830 printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK60" "$SOCK60" > "$LPHWALL"
831
832 set +e
833 XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
834 --out "$OUT.lphcap1" --err "$OUT.lphcap1.err" -- \
835 "$MUX" wall > "$OUT.lphpc1" 2>&1 <<'EOF'
836 expect lph-survivor-a 20000
837 settle 700 20000
838 send \x1cd
839 waitexit 10000
840 EOF
841 RC=$?
842 set -e
843 [ "$RC" -eq 0 ] || {
844 echo "e2e FAIL: layout-heal: first ptyclient exited $RC:"
845 cat "$OUT.lphpc1" "$OUT.lphcap1.err"; exit 1; }
846 [ -f "$LPHLAYOUT" ] || {
847 echo "e2e FAIL: layout-heal: sidecar not saved after first detach"
848 ls -la "$LPHSTATE/mux/" 2>&1; exit 1; }
849
850 # Drift: remove session a's line, add session c's line. The sidecar still
851 # describes [a, b]; the wall file now says [b, c]. On reattach, b is the
852 # survivor (matched), c inserts beside it, a collapses out (unmatched
853 # saved leaf).
854 printf -- '--sock %s#b\n--sock %s#c\n' "$SOCK60" "$SOCK60" > "$LPHWALL"
855
856 set +e
857 XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
858 --out "$OUT.lphcap2" --err "$OUT.lphcap2.err" -- \
859 "$MUX" wall > "$OUT.lphpc2" 2>&1 <<'EOF'
860 expect lph-removed-b 20000
861 settle 700 20000
862 send printf 'lph-heal-%s\n' marker\n
863 expect lph-heal-marker 10000
864 settle 500 15000
865 send \x1cd
866 waitexit 10000
867 EOF
868 RC=$?
869 set -e
870 [ "$RC" -eq 0 ] || {
871 echo "e2e FAIL: layout-heal: second ptyclient exited $RC:"
872 cat "$OUT.lphpc2" "$OUT.lphcap2.err"; exit 1; }
873 # The wall must not have refused: the ptyclient exited 0 (checked above),
874 # and stderr mentions no layout error. The survivor (b) still reaches its
875 # session — the marker typed after hydration landed in the focused tile,
876 # which is tile 0 (session b, the first wall-file line).
877 grep -qi "layout" "$OUT.lphcap2.err" && {
878 echo "e2e FAIL: layout-heal: stderr mentions layout (heal was not silent):"
879 cat "$OUT.lphcap2.err"; exit 1; }
880 # Two tiles present: the survivor (b) and the newcomer (c). The daemon
881 # has sessions a, b, and c; the wall shows b and c. muxa capture of b
882 # finds the survivor's marker.
883 timeout 20 "$MUXA" capture --sock "$SOCK60" --session b > "$OUT.lphfa" 2>&1
884 grep -q "lph-heal-marker" "$OUT.lphfa" || {
885 echo "e2e FAIL: layout-heal: survivor's marker not in session b:"
886 cat "$OUT.lphfa"; exit 1; }
887 assert_stopped "$SOCK60" "$D61PID" "layout-heal" "$OUT.lphstop"
888 D61PID=""
889 rm -rf "$LPHSTATE"
890 ok "the layout heals on wall-file drift: survivor keeps its session, newcomer joins"
891
892 # ---- layout sidecar: silent degrade on garbage -----------------------
893 #
894 # A corrupted sidecar (`bogus 9`) is not a fatal error: restoreLayout
895 # returns null, the default tree is built, and the wall comes up. The
896 # assertion is that a marker typed into the wall reaches the focused
897 # session (the wall works), and the client's stderr never mentions
898 # "layout" (the degrade is silent).
899 LPDSTATE="${TMPDIR:-/tmp}/mux-e2e-lpdegrade-state-$$"
900 defer_rm "$LPDSTATE"
901 LPDWALL="$LPDSTATE/mux/wall"
902 LPDLAYOUT="$LPDSTATE/mux/layout"
903 start_daemon "$SOCK61" "$OUT.lpd.d" "layout-degrade daemon never bound" --shell /bin/sh
904 D62PID=$DPID
905
906 pipe_mux "$OUT.lpda" "$OUT.lpda.err" timeout 40 "$MUX" --sock "$SOCK61" --session a
907 pipe_send 'printf "lpd-%%s\\n" origin\n'
908 await_out "$OUT.lpda" "lpd-origin" "layout-degrade: session a marker never reached the client"
909 pipe_detach
910 wait_grid "$SOCK61" "lpd-origin" "layout-degrade: session a marker" a
911
912 mkdir -p "$LPDSTATE/mux"
913 printf -- '--sock %s#a\n' "$SOCK61" > "$LPDWALL"
914 # A garbage sidecar: wrong magic, wrong version. restoreLayout returns
915 # null on the header check and the caller builds the default tree.
916 printf 'bogus 9\n' > "$LPDLAYOUT"
917
918 set +e
919 XDG_STATE_HOME="$LPDSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
920 --out "$OUT.lpdcap" --err "$OUT.lpdcap.err" -- \
921 "$MUX" wall > "$OUT.lpdpc" 2>&1 <<'EOF'
922 expect lpd-origin 20000
923 settle 700 20000
924 send printf 'lpd-degrade-%s\n' marker\n
925 expect lpd-degrade-marker 10000
926 settle 500 15000
927 send \x1cd
928 waitexit 10000
929 EOF
930 RC=$?
931 set -e
932 [ "$RC" -eq 0 ] || {
933 echo "e2e FAIL: layout-degrade: ptyclient exited $RC:"
934 cat "$OUT.lpdpc" "$OUT.lpdcap.err"; exit 1; }
935 # The wall came up: the marker reached the focused session.
936 timeout 20 "$MUXA" capture --sock "$SOCK61" --session a > "$OUT.lpdfa" 2>&1
937 grep -q "lpd-degrade-marker" "$OUT.lpdfa" || {
938 echo "e2e FAIL: layout-degrade: marker not in session a (wall did not come up):"
939 cat "$OUT.lpdfa"; exit 1; }
940 # The degrade was silent: no mention of "layout" on the client's stderr.
941 grep -qi "layout" "$OUT.lpdcap.err" && {
942 echo "e2e FAIL: layout-degrade: stderr mentions layout (degrade was not silent):"
943 cat "$OUT.lpdcap.err"; exit 1; }
944 assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop"
945 D62PID=""
946 rm -rf "$LPDSTATE"
947 ok "a corrupted sidecar degrades silently to the default layout"
948
949 # The hydrated-create leg. One daemon, and it serves QUIC as well as its 148 # The hydrated-create leg. One daemon, and it serves QUIC as well as its
950 # socket: the leg's whole point is the LOCAL/remote split, so both halves 149 # socket: the leg's whole point is the LOCAL/remote split, so both halves
951 # have to be the same run of the same daemon or the comparison is between 150 # have to be the same run of the same daemon or the comparison is between
test/e2e_12_panes.sh
Old New
@@ -0,0 +1,804 @@
1 # shellcheck shell=sh
2 # e2e_12_panes.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 scrollback-rect leg: a focused tile's history page must own only its
6 # sub-rect, so its daemon gets a socket of its own for the grid-size
7 # reason every wall leg has.
8 SOCK53="${TMPDIR:-/tmp}/muxd-e2e-sbrect-$$.sock"
9 defer_sock "$SOCK53"
10 SOCK54="${TMPDIR:-/tmp}/muxd-e2e-fullscreen-$$.sock"
11 defer_sock "$SOCK54"
12 SOCK55="${TMPDIR:-/tmp}/muxd-e2e-resize-$$.sock"
13 defer_sock "$SOCK55"
14 SOCK56="${TMPDIR:-/tmp}/muxd-e2e-spanclear-$$.sock"
15 defer_sock "$SOCK56"
16 SOCK57="${TMPDIR:-/tmp}/muxd-e2e-hjkl-$$.sock"
17 defer_sock "$SOCK57"
18 SOCK58="${TMPDIR:-/tmp}/muxd-e2e-splitbirth-$$.sock"
19 defer_sock "$SOCK58"
20 SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock"
21 defer_sock "$SOCK59"
22 SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock"
23 defer_sock "$SOCK60"
24 SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock"
25 defer_sock "$SOCK61"
26 SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock"
27 defer_sock "$SOCK62"
28 SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock"
29 defer_sock "$SOCK63"
30
31 # --- the cursor rests in the focused tile, whoever painted last ----------
32 # The cursor-ownership leg: a daemon of its own, for the grid-size reason
33 # every wall leg has — the assertion is about where the cursor RESTS, and a
34 # session another block created would change the tile it rests in.
35 SOCK52="${TMPDIR:-/tmp}/muxd-e2e-cursor-$$.sock"
36 defer_sock "$SOCK52"
37 #
38 # Every painter ends by showing the cursor where its own stripe sits, so
39 # without an owner the visible cursor lands on whichever pump painted last:
40 # focus on tile 1, a prompt redraw in tile 2, and the eye is told 2 is live
41 # while the keys go elsewhere. The focused tile's paint now records its
42 # screen cursor in Shared, and an unfocused paint's last act is to put the
43 # cursor back there. This leg focuses tile 3 of a three-tile wall and
44 # asserts the terminal's final cursor position is in tile 3's stripe —
45 # deterministic, because every paint now ends at the focused tile's cursor.
46 #
47 # Three sessions on a daemon of its own, each marked by its shell before the
48 # wall attaches (the shell-expanded marker trick: a hit is the shell's work,
49 # never an echo of anything typed here). The ptyclient wall focuses tile 3,
50 # settles, and detaches; the capture's last cursor-position escape is the
51 # witness.
52 start_daemon "$SOCK52" "$OUT.cu.d" "cursor-ownership daemon never bound" --shell /bin/sh
53 D52PID=$DPID
54
55 pipe_mux "$OUT.cua" "$OUT.cua.err" timeout 40 "$MUX" --sock "$SOCK52" --session a
56 pipe_send 'printf "ma-%%s\\n" pin\n'
57 await_out "$OUT.cua" "ma-pin" "cursor: session a's marker never reached the client"
58 pipe_detach
59 wait_grid "$SOCK52" "ma-pin" "cursor: session a's marker" a
60 pipe_mux "$OUT.cub" "$OUT.cub.err" timeout 40 "$MUX" --sock "$SOCK52" --session b
61 pipe_send 'printf "mb-%%s\\n" pin\n'
62 await_out "$OUT.cub" "mb-pin" "cursor: session b's marker never reached the client"
63 pipe_detach
64 wait_grid "$SOCK52" "mb-pin" "cursor: session b's marker" b
65 pipe_mux "$OUT.cuc" "$OUT.cuc.err" timeout 40 "$MUX" --sock "$SOCK52" --session c
66 pipe_send 'printf "mc-%%s\\n" pin\n'
67 await_out "$OUT.cuc" "mc-pin" "cursor: session c's marker never reached the client"
68 pipe_detach
69 wait_grid "$SOCK52" "mc-pin" "cursor: session c's marker" c
70
71 set +e
72 timeout 40 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.cucap" --err "$OUT.cucap.err" -- \
73 "$MUX" wall --sock "$SOCK52#a" "--sock $SOCK52#b" "--sock $SOCK52#c" > "$OUT.cupc" 2>&1 <<'EOF'
74 expect mc-pin 20000
75 settle 500 15000
76 send \x1c3
77 settle 500 15000
78 send \x1cd
79 waitexit 10000
80 EOF
81 RC=$?
82 set -e
83 [ "$RC" -eq 0 ] || {
84 echo "e2e FAIL: cursor-ownership: ptyclient leg exited $RC:"
85 cat "$OUT.cupc" "$OUT.cucap.err"; exit 1; }
86 # Rows 21-30 are tile 3's stripe (3 tiles on 30 rows: stripes at 1/11/21).
87 # The focused tile's paint records its cursor; an unfocused paint's last act
88 # is a bare CUP putting it back there, with no show of its own. So the
89 # terminal's final cursor position — the last CUP in the capture, show or
90 # none — rests in tile 3's stripe whichever pump painted last. The last
91 # CUP+show would be the last painter's OWN cursor (racy); the last CUP of
92 # any kind is the focused tile's, and that is what the fix owns.
93 _cup_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H'
94 _last_cup=$(grep -ao "$_cup_re" "$OUT.cucap" | tail -1)
95 # _last_cup is ESC[<row>;<col>H; peel the ESC[ prefix and the ;colH suffix.
96 _cur_row=${_last_cup#??}
97 _cur_row=${_cur_row%%;*}
98 [ -n "$_cur_row" ] && [ "$_cur_row" -ge 21 ] && [ "$_cur_row" -le 30 ] || {
99 echo "e2e FAIL: cursor-ownership: the last CUP was '$_last_cup' (row ${_cur_row:-none}), want tile 3's stripe (rows 21-30):"
100 cat "$OUT.cucap"; exit 1; }
101 assert_stopped "$SOCK52" "$D52PID" "cursor-ownership" "$OUT.custop"
102 D52PID=""
103 ok "the cursor rests in the focused tile, whichever pump painted last"
104
105 # ---- a focused tile's scrollback page owns only its sub-rect ----------
106 #
107 # The one-tile wheel leg above has no label bar, so its geometry is the
108 # plain client's: the one paint at row 0 that DOES own the whole screen.
109 # That exemption is what let a scrollback page at a row_off through that
110 # opened with `\x1b[H\x1b[2J` — clearing the whole terminal and painting
111 # history at row 1, while the exit path repaints only the focused tile's
112 # own rect, so every neighbour stayed blank. This leg is the twin that
113 # catches it: two tiles over 30 rows, the focused one scrolls back on the
114 # wheel, and the neighbour's pin must still be the last content painted in
115 # its rows, with no whole-screen clear following it.
116 #
117 # Two sessions on one daemon. The focused tile (a, the first spelling)
118 # fills a scrollback the wheel can page into; the neighbour (b) holds a
119 # pin and is never touched again. Both shells are /bin/sh and the content
120 # is typed, the focus-skip leg's pattern — the neighbour's pin is the
121 # session's own output, not an echo of the command that made it.
122 start_daemon "$SOCK53" "$OUT.sb2.d" "scrollback-rect daemon never bound" --shell /bin/sh
123 D54PID=$DPID
124
125 # Session a: 100 lines of history, then cat. The wall attaches only once
126 # seq has finished, so the page the wheel fetches is content this tile was
127 # never sent live — the wheel leg's reason, verbatim.
128 pipe_mux "$OUT.sb2a" "$OUT.sb2a.err" timeout 40 "$MUX" --sock "$SOCK53" --session a
129 pipe_send 'seq 1 100 | sed '\''s/^/wln/'\''; exec cat\n'
130 await_out "$OUT.sb2a" "wln100" "scrollback-rect: session a never finished seq"
131 pipe_detach
132 wait_grid "$SOCK53" "wln100" "scrollback-rect: session a's tail" a
133
134 # Session b: a pin the capture can grep for, then cat. `printf "sb2%s\n"
135 # nbr` so the pin is the output, not the echoed command — the focus-skip
136 # leg's prefix trick.
137 pipe_mux "$OUT.sb2b" "$OUT.sb2b.err" timeout 40 "$MUX" --sock "$SOCK53" --session b
138 pipe_send 'printf "sb2%%s\\n" nbr; exec cat\n'
139 await_out "$OUT.sb2b" "sb2nbr" "scrollback-rect: session b's pin never reached the client"
140 pipe_detach
141 wait_grid "$SOCK53" "sb2nbr" "scrollback-rect: session b's pin" b
142
143 set +e
144 timeout 90 "$PTYCLIENT" --cols 40 --rows 30 --out "$OUT.sb2cap" --err "$OUT.sb2cap.err" -- \
145 "$MUX" wall "--sock $SOCK53#a" "--sock $SOCK53#b" > "$OUT.sb2pc" 2>&1 <<'EOF'
146 expect sb2nbr 20000
147 settle 700 20000
148 send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M
149 expect \x1b[7m[scroll] 15000
150 settle 500 15000
151 send \x1cd
152 waitexit 10000
153 EOF
154 RC=$?
155 set -e
156 [ "$RC" -eq 0 ] || {
157 echo "e2e FAIL: scrollback-rect: ptyclient leg exited $RC (did the focused wheel scroll?):"
158 cat "$OUT.sb2pc"; exit 1; }
159 # The neighbour's pin is still the last content painted in its rows: the
160 # capture holds it, and nothing after it cleared the whole screen. A
161 # scrollback page that owned the whole terminal (the bug) would open with
162 # `\x1b[H\x1b[2J` right here, wiping the neighbour's rows; the fix clears
163 # per row inside the focused tile's sub-rect only.
164 grep -qaF "sb2nbr" "$OUT.sb2cap" || {
165 echo "e2e FAIL: scrollback-rect: the neighbour's pin left the capture:"
166 cat "$OUT.sb2pc"; exit 1; }
167 _sb2last=$(grep -aboF -- "sb2nbr" "$OUT.sb2cap" | tail -1 | cut -d: -f1)
168 tail -c +$((_sb2last + 1)) "$OUT.sb2cap" | grep -aF -- "$(printf '\x1b[2J')" && {
169 echo "e2e FAIL: scrollback-rect: a whole-screen clear followed the neighbour's paint:"
170 cat "$OUT.sb2pc"; exit 1; }
171 assert_stopped "$SOCK53" "$D54PID" "scrollback-rect" "$OUT.sb2stop"
172 D54PID=""
173 ok "a focused tile's scrollback page owns only its sub-rect, and the neighbour's pin stays"
174
175 # ---- fullscreen: f toggles, focus follows, restore -------------------
176 #
177 # `f` is a layout op, not a mode: the focused tile takes the whole
178 # terminal, every other gets 0x0. A 0x0 rect claims nothing, so hidden
179 # panes stay attached and paint nothing. Focus moves while fullscreened
180 # re-flatten, so Ctrl-\ 2 gives tile 2 the full rect. `f` again restores
181 # the real layout. This leg proves all three with `muxa capture` — the
182 # marker each session received names which tile owned the terminal.
183 #
184 # No convergence point: `muxa capture` reads the daemon's grid, not the
185 # terminal's, and the daemon's grid is the side channel this asserts on.
186 start_daemon "$SOCK54" "$OUT.fs.d" "fullscreen daemon never bound" --shell /bin/sh
187 D55PID=$DPID
188
189 pipe_mux "$OUT.fsa" "$OUT.fsa.err" timeout 40 "$MUX" --sock "$SOCK54" --session a
190 pipe_send 'printf "fs-%%s\\n" init-a\n'
191 await_out "$OUT.fsa" "fs-init-a" "fullscreen: session a's marker never reached the client"
192 pipe_detach
193 wait_grid "$SOCK54" "fs-init-a" "fullscreen: session a's marker" a
194 pipe_mux "$OUT.fsb" "$OUT.fsb.err" timeout 40 "$MUX" --sock "$SOCK54" --session b
195 pipe_send 'printf "fs-%%s\\n" init-b\n'
196 await_out "$OUT.fsb" "fs-init-b" "fullscreen: session b's marker never reached the client"
197 pipe_detach
198 wait_grid "$SOCK54" "fs-init-b" "fullscreen: session b's marker" b
199
200 set +e
201 # wide: 80x24 trips the aspect rule (80 >= 48 → beside)
202 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.fscap" --err "$OUT.fscap.err" -- \
203 "$MUX" wall "--sock $SOCK54#a" "--sock $SOCK54#b" > "$OUT.fspc" 2>&1 <<'EOF'
204 expect fs-init-b 20000
205 settle 700 20000
206 send \x1cf
207 settle 700 20000
208 send printf 'fs-%s\n' one\n
209 expect fs-one 10000
210 settle 400 15000
211 send \x1c2
212 settle 700 20000
213 send printf 'fs-%s\n' two\n
214 expect fs-two 10000
215 settle 400 15000
216 send \x1cf
217 settle 700 20000
218 send \x1cd
219 waitexit 10000
220 EOF
221 RC=$?
222 set -e
223 [ "$RC" -eq 0 ] || {
224 echo "e2e FAIL: fullscreen: ptyclient leg exited $RC:"
225 cat "$OUT.fspc" "$OUT.fscap.err"; exit 1; }
226 # fs-one landed in session a (the tile that was fullscreened first);
227 # fs-two landed in session b (the tile Ctrl-\ 2 moved the full rect to).
228 timeout 20 "$MUXA" capture --sock "$SOCK54" --session a > "$OUT.fsfa" 2>&1
229 timeout 20 "$MUXA" capture --sock "$SOCK54" --session b > "$OUT.fsfb" 2>&1
230 grep -q "fs-one" "$OUT.fsfa" || {
231 echo "e2e FAIL: fullscreen: session a never got fs-one:"
232 cat "$OUT.fsfa"; exit 1; }
233 grep -q "fs-one" "$OUT.fsfb" && {
234 echo "e2e FAIL: fullscreen: fs-one reached session b, which was hidden:"
235 cat "$OUT.fsfb"; exit 1; }
236 grep -q "fs-two" "$OUT.fsfb" || {
237 echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not move the full rect to session b:"
238 cat "$OUT.fsfb"; exit 1; }
239 grep -q "fs-two" "$OUT.fsfa" && {
240 echo "e2e FAIL: fullscreen: fs-two reached session a, which was hidden after the focus move:"
241 cat "$OUT.fsfa"; exit 1; }
242 # What the human saw after Ctrl-\ 2: the `>` moved and the digits did not.
243 # A bar's digit is the chord that reaches that tile for its whole life, not
244 # a count of who holds the focus — the render oracle is the only witness,
245 # since the daemon's grid has no bars in it. The label is cut to the stripe
246 # at 80 columns beside, so the marker and the head of the spelling are what
247 # survive; the digit is what tells the two stripes apart.
248 "$RENDER" --cols 80 --rows 24 < "$OUT.fscap" > "$OUT.fsgrid" || {
249 echo "e2e FAIL: fullscreen: render oracle failed"
250 cat "$OUT.fsgrid"; exit 1; }
251 grep -q -- " 2> --sock" "$OUT.fsgrid" || {
252 echo "e2e FAIL: fullscreen: Ctrl-\\ 2 did not leave the \`>\` on tile 2's digit:"
253 cat "$OUT.fsgrid"; exit 1; }
254 grep -q -- " 1 --sock" "$OUT.fsgrid" || {
255 echo "e2e FAIL: fullscreen: tile 1 lost its digit or kept the \`>\`:"
256 cat "$OUT.fsgrid"; exit 1; }
257 assert_stopped "$SOCK54" "$D55PID" "fullscreen" "$OUT.fsstop"
258 D55PID=""
259 ok "fullscreen gives the focused tile the terminal; focus follows; f restores"
260
261 # ---- Ctrl-\ r trades cells between panes -----------------------------
262 #
263 # Two beside panes at 80x24 (aspect rule → beside). Ctrl-\ r enters
264 # resize mode; lll grows the focused pane's width by 3 cells, moving the
265 # rail 3 columns right. The rail is a reverse-video space painted at a
266 # CUP column; grep the column before and after. Esc exits resize mode,
267 # and typed prose lands in the focused session (marker echo).
268 #
269 # No convergence point: the rail column is a terminal-capture assertion,
270 # not a daemon-grid comparison.
271 start_daemon "$SOCK55" "$OUT.rsz.d" "resize daemon never bound" --shell /bin/sh
272 D56PID=$DPID
273
274 pipe_mux "$OUT.rsza" "$OUT.rsza.err" timeout 40 "$MUX" --sock "$SOCK55" --session a
275 pipe_send 'printf "rsz-%%s\\n" init-a\n'
276 await_out "$OUT.rsza" "rsz-init-a" "resize: session a's marker never reached the client"
277 pipe_detach
278 wait_grid "$SOCK55" "rsz-init-a" "resize: session a's marker" a
279 pipe_mux "$OUT.rszb" "$OUT.rszb.err" timeout 40 "$MUX" --sock "$SOCK55" --session b
280 pipe_send 'printf "rsz-%%s\\n" init-b\n'
281 await_out "$OUT.rszb" "rsz-init-b" "resize: session b's marker never reached the client"
282 pipe_detach
283 wait_grid "$SOCK55" "rsz-init-b" "resize: session b's marker" b
284
285 set +e
286 # 80x24: aspect rule makes beside; two panes split 39/40 with the rail
287 # at column 40 (1-indexed). Three l presses grow the left pane by 3,
288 # moving the rail to column 43.
289 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.rszcap" --err "$OUT.rszcap.err" -- \
290 "$MUX" wall "--sock $SOCK55#a" "--sock $SOCK55#b" > "$OUT.rszpc" 2>&1 <<'EOF'
291 expect rsz-init-b 20000
292 settle 700 20000
293 send \x1cr
294 settle 300 10000
295 send l
296 settle 300 10000
297 send l
298 settle 300 10000
299 send l
300 settle 500 15000
301 send \x1b
302 settle 300 10000
303 send printf 'rsz-%s\n' grown\n
304 expect rsz-grown 10000
305 settle 400 15000
306 send \x1cd
307 waitexit 10000
308 EOF
309 RC=$?
310 set -e
311 [ "$RC" -eq 0 ] || {
312 echo "e2e FAIL: resize: ptyclient leg exited $RC:"
313 cat "$OUT.rszpc" "$OUT.rszcap.err"; exit 1; }
314 # The rail paints as ESC[row;colH followed by ESC[7m (reverse video).
315 # Extract the column from every CUP that precedes a reverse-video
316 # space — the rail's column — and verify it moved right by 3.
317 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
318 # Before resize: rail at column 40 (1-indexed). After lll: column 43.
319 # Grab all rail CUP columns; the last one is the post-resize position.
320 _rail_cols=$(grep -ao "$_rail_re" "$OUT.rszcap" | \
321 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n)
322 _last_rail=$(echo "$_rail_cols" | tail -1)
323 [ -n "$_last_rail" ] || {
324 echo "e2e FAIL: resize: no rail glyph found in the capture:"
325 cat "$OUT.rszcap"; exit 1; }
326 # 39 cols + rail at 40 initially; 42 cols + rail at 43 after lll.
327 [ "$_last_rail" -ge 43 ] || {
328 echo "e2e FAIL: resize: rail column is $_last_rail, want >=43 (40 + 3 grows):"
329 cat "$OUT.rszcap"; exit 1; }
330 # Esc ended resize mode and typed prose reached the focused session.
331 timeout 20 "$MUXA" capture --sock "$SOCK55" --session a > "$OUT.rszsta" 2>&1
332 timeout 20 "$MUXA" capture --sock "$SOCK55" --session b > "$OUT.rszstb" 2>&1
333 if ! grep -q "rsz-grown" "$OUT.rszsta" && ! grep -q "rsz-grown" "$OUT.rszstb"; then
334 echo "e2e FAIL: resize: rsz-grown reached neither session after Esc:"
335 cat "$OUT.rszsta" "$OUT.rszstb"; exit 1
336 fi
337 assert_stopped "$SOCK55" "$D56PID" "resize" "$OUT.rszstop"
338 D56PID=""
339 ok "Ctrl-\\ r trades cells between panes; Esc returns to prose"
340
341 # ---- span-clear survival: the engine oracle -------------------------
342 #
343 # Two beside panes at 80x24 (aspect rule: cols >= 2*rows → beside). A
344 # marker in the RIGHT pane's session, then a flood in the LEFT pane's
345 # session (seq 1 200). The regression this catches forever is a whole-line
346 # clear (\x1b[2K) blanking the neighbor: a painter that emits too much
347 # still passes a byte grep, so the assertion replays the wall client's
348 # FULL captured stdout through the render fixture (an engine, not a grep)
349 # and checks the right pane's marker text survives on the replayed grid.
350 start_daemon "$SOCK56" "$OUT.spc.d" "span-clear daemon never bound" --shell /bin/sh
351 D57PID=$DPID
352
353 pipe_mux "$OUT.spca" "$OUT.spca.err" timeout 40 "$MUX" --sock "$SOCK56" --session a
354 pipe_send 'printf "spc-%%s\\n" left-flood\n'
355 await_out "$OUT.spca" "spc-left-flood" "span-clear: left session's marker never reached the client"
356 pipe_detach
357 wait_grid "$SOCK56" "spc-left-flood" "span-clear: left session's marker" a
358 pipe_mux "$OUT.spcb" "$OUT.spcb.err" timeout 40 "$MUX" --sock "$SOCK56" --session b
359 pipe_send 'printf "spc-%%s\\n" right-survivor\n'
360 await_out "$OUT.spcb" "spc-right-survivor" "span-clear: right session's marker never reached the client"
361 pipe_detach
362 wait_grid "$SOCK56" "spc-right-survivor" "span-clear: right session's marker" b
363
364 set +e
365 # 80x24: aspect rule → beside. The wall client is a tty session: its
366 # stdout is the terminal's full byte stream, captured by ptyclient.
367 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.spccap" --err "$OUT.spccap.err" -- \
368 "$MUX" wall "--sock $SOCK56#a" "--sock $SOCK56#b" > "$OUT.spcpc" 2>&1 <<'EOF'
369 expect spc-right-survivor 20000
370 settle 700 20000
371 send \x1ch
372 settle 500 15000
373 send seq 1 200\n
374 expect 200 20000
375 settle 700 20000
376 send \x1cd
377 waitexit 10000
378 EOF
379 RC=$?
380 set -e
381 [ "$RC" -eq 0 ] || {
382 echo "e2e FAIL: span-clear: ptyclient leg exited $RC:"
383 cat "$OUT.spcpc" "$OUT.spccap.err"; exit 1; }
384 # The oracle: replay the captured stream through the render fixture at the
385 # same dimensions, producing the final grid text. The right pane's marker
386 # must still be on that grid — a span-clear regression blanks it.
387 "$RENDER" --cols 80 --rows 24 < "$OUT.spccap" > "$OUT.spcgrid" || {
388 echo "e2e FAIL: span-clear: render fixture failed on the capture:"
389 cat "$OUT.spcgrid"; exit 1; }
390 grep -q "spc-right-survivor" "$OUT.spcgrid" || {
391 echo "e2e FAIL: span-clear: right pane's marker lost from the replayed grid:"
392 echo " a span-clear regression blanked the neighbor:"
393 cat "$OUT.spcgrid"; exit 1; }
394 assert_stopped "$SOCK56" "$D57PID" "span-clear" "$OUT.spcstop"
395 D57PID=""
396 ok "a span-clear flood in the left pane cannot blank the right pane's grid"
397
398 # ---- hjkl focus walk -------------------------------------------------
399 #
400 # One tile, split right (births a second session), split below (births a
401 # third). Then walk focus with hjkl and type a distinct marker after each
402 # move; muxa capture names which session received it. Covers at least a
403 # left move, a right move, and one vertical move.
404 #
405 # No convergence point: the assertion is per-session muxa capture, not a
406 # daemon-grid diff.
407 start_daemon "$SOCK57" "$OUT.hjk.d" "hjkl daemon never bound" --shell /bin/sh
408 D58PID=$DPID
409
410 pipe_mux "$OUT.hjkcap" "$OUT.hjkcap.err" timeout 40 "$MUX" --sock "$SOCK57" --session a
411 pipe_send 'printf "hjk-%%s\\n" origin\n'
412 await_out "$OUT.hjkcap" "hjk-origin" "hjkl: origin marker never reached the client"
413 pipe_detach
414 wait_grid "$SOCK57" "hjk-origin" "hjkl: origin marker" a
415
416 set +e
417 # 80x24: aspect rule → beside for the first split; the second split goes
418 # below, producing a 1 | (2 over 3) tree.
419 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.hjkcap" --err "$OUT.hjkcap.err" -- \
420 "$MUX" wall "--sock $SOCK57#a" > "$OUT.hjkpc" 2>&1 <<'EOF'
421 expect hjk-origin 20000
422 settle 700 20000
423 send \x1c|
424 settle 1000 20000
425 send \x1c-
426 settle 1000 20000
427 send \x1ch
428 settle 500 15000
429 send printf 'hjk-%s\n' went-left\n
430 expect hjk-went-left 10000
431 settle 500 15000
432 send \x1cl
433 settle 500 15000
434 send printf 'hjk-%s\n' went-right\n
435 expect hjk-went-right 10000
436 settle 500 15000
437 send \x1cj
438 settle 500 15000
439 send printf 'hjk-%s\n' went-down\n
440 expect hjk-went-down 10000
441 settle 500 15000
442 send \x1cd
443 waitexit 10000
444 EOF
445 RC=$?
446 set -e
447 [ "$RC" -eq 0 ] || {
448 echo "e2e FAIL: hjkl: ptyclient leg exited $RC:"
449 cat "$OUT.hjkpc" "$OUT.hjkcap.err"; exit 1; }
450 # Three sessions exist now: a (the origin), and two birthed by splits.
451 # The daemon assigns numeric names to the new sessions; capture all three.
452 timeout 20 "$MUXA" capture --sock "$SOCK57" --session a > "$OUT.hjkfa" 2>&1
453 timeout 20 "$MUXA" capture --sock "$SOCK57" --session 1 > "$OUT.hjkfb" 2>&1
454 timeout 20 "$MUXA" capture --sock "$SOCK57" --session 2 > "$OUT.hjkfc" 2>&1
455 # went-left: typed after \x1ch from the right pane → lands in session a.
456 grep -q "hjk-went-left" "$OUT.hjkfa" || {
457 echo "e2e FAIL: hjkl: hjk-went-left not in session a (left focus move):"
458 cat "$OUT.hjkfa"; exit 1; }
459 # went-right: typed after \x1cl from session a → lands in session 1.
460 grep -q "hjk-went-right" "$OUT.hjkfb" || {
461 echo "e2e FAIL: hjkl: hjk-went-right not in session 1 (right focus move):"
462 cat "$OUT.hjkfb"; exit 1; }
463 # went-down: typed after \x1cj from session 1 → lands in session 2.
464 grep -q "hjk-went-down" "$OUT.hjkfc" || {
465 echo "e2e FAIL: hjkl: hjk-went-down not in session 2 (down focus move):"
466 cat "$OUT.hjkfc"; exit 1; }
467 assert_stopped "$SOCK57" "$D58PID" "hjkl" "$OUT.hjkstop"
468 D58PID=""
469 ok "hjkl walks focus across panes in both axes"
470
471 # ---- split births a session beside -----------------------------------
472 #
473 # From one pane, Ctrl-\ | splits right and births a new session. The
474 # daemon's session count rises to two, and the new pane's label bar sits
475 # at a column to the right of the rail (the rail paints as ESC[r;cH
476 # ESC[7m — the same idiom the resize leg uses).
477 start_daemon "$SOCK58" "$OUT.spl.d" "split-birth daemon never bound" --shell /bin/sh
478 D59PID=$DPID
479
480 pipe_mux "$OUT.splcap" "$OUT.splcap.err" timeout 40 "$MUX" --sock "$SOCK58" --session a
481 pipe_send 'printf "spl-%%s\\n" origin\n'
482 await_out "$OUT.splcap" "spl-origin" "split-birth: origin marker never reached the client"
483 pipe_detach
484 wait_grid "$SOCK58" "spl-origin" "split-birth: origin marker" a
485
486 set +e
487 # 80x24: aspect rule → beside. One tile starts, then Ctrl-\ | births a
488 # second session to the right.
489 timeout 90 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.splcap" --err "$OUT.splcap.err" -- \
490 "$MUX" wall "--sock $SOCK58#a" > "$OUT.splpc" 2>&1 <<'EOF'
491 expect spl-origin 20000
492 settle 700 20000
493 send \x1c|
494 settle 1500 20000
495 send \x1cd
496 waitexit 10000
497 EOF
498 RC=$?
499 set -e
500 [ "$RC" -eq 0 ] || {
501 echo "e2e FAIL: split-birth: ptyclient leg exited $RC:"
502 cat "$OUT.splpc" "$OUT.splcap.err"; exit 1; }
503 # The daemon held sessions 0 (default) and a; the split birthed one
504 # more, so three live sessions proves the chord created a new session.
505 wait_sessions "$SOCK58" 3 "split-birth: split did not birth a new session"
506 # The rail paints as ESC[row;colH ESC[7m (reverse video). The new pane's
507 # label bar is at a column greater than the rail's column — it sits to
508 # the right of the separator, which is the whole point of beside.
509 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
510 _rail_col=$(grep -ao "$_rail_re" "$OUT.splcap" | \
511 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | head -1)
512 [ -n "$_rail_col" ] || {
513 echo "e2e FAIL: split-birth: no rail glyph found in the capture:"
514 cat "$OUT.splcap"; exit 1; }
515 # The rail sits at the boundary; the new pane's label bar is beyond it.
516 # A label bar is a CUP followed by text, not reverse-video. Find a CUP
517 # whose column exceeds the rail's — that is the right pane's bar start.
518 _bar_col=$(grep -ao $'\x1b\\[[0-9]*;[0-9]*H' "$OUT.splcap" | \
519 sed 's/\x1b\[[0-9]*;\([0-9]*\)H/\1/' | sort -n | \
520 awk -v r="$_rail_col" '$1 > r { print; exit }')
521 [ -n "$_bar_col" ] || {
522 echo "e2e FAIL: split-birth: no label bar found right of the rail (col $_rail_col):"
523 cat "$OUT.splcap"; exit 1; }
524 assert_stopped "$SOCK58" "$D59PID" "split-birth" "$OUT.splstop"
525 D59PID=""
526 ok "Ctrl-\\ | births a session beside; its label bar sits right of the rail"
527
528 # ---- layout sidecar: restore round-trip ------------------------------
529 #
530 # A no-argv `mux wall` hydrates from the wall file, and on Ctrl-\ d the
531 # resized tree is saved to the layout sidecar. Reattaching with the same
532 # state home loads the sidecar verbatim — the resize survived the round
533 # trip. The rail's CUP column in the second capture is >= 43 (three
534 # right-resizes from a 40|40 split), and a marker typed into the restored
535 # wall lands in the focused session.
536 LPSTATE="${TMPDIR:-/tmp}/mux-e2e-lprrestore-state-$$"
537 defer_rm "$LPSTATE"
538 LPWALL="$LPSTATE/mux/wall"
539 LPLAYOUT="$LPSTATE/mux/layout"
540 start_daemon "$SOCK59" "$OUT.lpr.d" "layout-restore daemon never bound" --shell /bin/sh
541 D60PID=$DPID
542
543 pipe_mux "$OUT.lpra" "$OUT.lpra.err" timeout 40 "$MUX" --sock "$SOCK59" --session a
544 pipe_send 'printf "lpr-%%s\\n" origin-a\n'
545 await_out "$OUT.lpra" "lpr-origin-a" "layout-restore: session a marker never reached the client"
546 pipe_detach
547 wait_grid "$SOCK59" "lpr-origin-a" "layout-restore: session a marker" a
548 pipe_mux "$OUT.lprb" "$OUT.lprb.err" timeout 40 "$MUX" --sock "$SOCK59" --session b
549 pipe_send 'printf "lpr-%%s\\n" origin-b\n'
550 await_out "$OUT.lprb" "lpr-origin-b" "layout-restore: session b marker never reached the client"
551 pipe_detach
552 wait_grid "$SOCK59" "lpr-origin-b" "layout-restore: session b marker" b
553
554 # Write the wall file by hand: two spellings, one per line. The no-argv
555 # `mux wall` reads this on startup — the hydration path.
556 mkdir -p "$LPSTATE/mux"
557 printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK59" "$SOCK59" > "$LPWALL"
558
559 set +e
560 # First ptyclient: hydrate, resize three right, detach. The detach saves
561 # the sidecar.
562 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
563 --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \
564 "$MUX" wall > "$OUT.lprpc1" 2>&1 <<'EOF'
565 expect lpr-origin-a 20000
566 settle 700 20000
567 send \x1cr
568 settle 300 5000
569 send l
570 settle 300 5000
571 send l
572 settle 300 5000
573 send l
574 settle 500 5000
575 send \x1b
576 settle 500 5000
577 send \x1cl
578 settle 500 5000
579 send \x1cd
580 waitexit 10000
581 EOF
582 RC=$?
583 set -e
584 [ "$RC" -eq 0 ] || {
585 echo "e2e FAIL: layout-restore: first ptyclient exited $RC:"
586 cat "$OUT.lprpc1" "$OUT.lprcap1.err"; exit 1; }
587
588 # The sidecar exists and its header is the format the spec mandates.
589 [ -f "$LPLAYOUT" ] || {
590 echo "e2e FAIL: layout-restore: sidecar not saved at $LPLAYOUT"
591 ls -la "$LPSTATE/mux/" 2>&1; exit 1; }
592 _head1=$(head -1 "$LPLAYOUT")
593 _head2=$(sed -n '2p' "$LPLAYOUT")
594 [ "$_head1" = "mux-layout 1" ] || {
595 echo "e2e FAIL: layout-restore: sidecar header is '$_head1', want 'mux-layout 1'"
596 cat "$LPLAYOUT"; exit 1; }
597 # A 2-pane beside at 80 cols: the root is `beside 0`.
598 case "$_head2" in
599 "beside 0") ;;
600 *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'"
601 cat "$LPLAYOUT"; exit 1 ;;
602 esac
603 # The focus record: focus-right moved focus to pane b (leaf 1).
604 grep -q '^focus 1$' "$LPLAYOUT" || {
605 echo "e2e FAIL: layout-restore: sidecar carries no focus record"
606 cat "$LPLAYOUT"; exit 1; }
607
608 set +e
609 # Second ptyclient: reattach. The sidecar restores the resized tree —
610 # the rail sits at column >= 43. Type a marker, then detach.
611 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
612 --out "$OUT.lprcap2" --err "$OUT.lprcap2.err" -- \
613 "$MUX" wall > "$OUT.lprpc2" 2>&1 <<'EOF'
614 expect lpr-origin-a 20000
615 settle 1000 20000
616 send printf 'lpr-rt-%s\n' marker\n
617 expect lpr-rt-marker 10000
618 settle 500 15000
619 send \x1cd
620 waitexit 10000
621 EOF
622 RC=$?
623 set -e
624 [ "$RC" -eq 0 ] || {
625 echo "e2e FAIL: layout-restore: second ptyclient exited $RC:"
626 cat "$OUT.lprpc2" "$OUT.lprcap2.err"; exit 1; }
627 # The rail's CUP column in the second capture must be >= 43 — three
628 # right-resizes moved the boundary from 41 to 44. The _rail_re idiom
629 # (from the split-birth leg) matches ESC[row;colH ESC[7m.
630 _rail_re=$'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m'
631 _max_rail=$(grep -ao "$_rail_re" "$OUT.lprcap2" | \
632 sed 's/.*\x1b\[[0-9]*;\([0-9]*\)H\x1b\[7m/\1/' | sort -n | tail -1)
633 [ -n "$_max_rail" ] || {
634 echo "e2e FAIL: layout-restore: no rail glyph in the second capture:"
635 cat "$OUT.lprcap2"; exit 1; }
636 [ "$_max_rail" -ge 43 ] || {
637 echo "e2e FAIL: layout-restore: rail column $_max_rail < 43 (resize did not survive)"
638 cat "$OUT.lprcap2"; exit 1; }
639 # The marker typed into the restored wall landed in session b — the
640 # focus was saved on pane b and restore put it back there, not tile 0.
641 timeout 20 "$MUXA" capture --sock "$SOCK59" --session b > "$OUT.lprfb" 2>&1
642 grep -q "lpr-rt-marker" "$OUT.lprfb" || {
643 echo "e2e FAIL: layout-restore: marker not in session b (focus did not survive):"
644 cat "$OUT.lprfb"; exit 1; }
645 timeout 20 "$MUXA" capture --sock "$SOCK59" --session a > "$OUT.lprfa" 2>&1
646 ! grep -q "lpr-rt-marker" "$OUT.lprfa" || {
647 echo "e2e FAIL: layout-restore: marker leaked into session a (focus was tile 0, not b):"
648 cat "$OUT.lprfa"; exit 1; }
649 assert_stopped "$SOCK59" "$D60PID" "layout-restore" "$OUT.lprstop"
650 D60PID=""
651 rm -rf "$LPSTATE"
652 ok "a resized layout survives a detach/reattach round trip via the sidecar"
653
654 # ---- layout sidecar: healing on wall-file drift ----------------------
655 #
656 # After a 2-pane sidecar is saved, the wall file drifts: one original is
657 # removed and a new spelling is added. On reattach, restoreLayout heals —
658 # the survivor leaf keeps its session, the new tile inserts beside it,
659 # and the unmatched saved leaf collapses out. Two tiles, not three, and
660 # the wall did not refuse.
661 LPHSTATE="${TMPDIR:-/tmp}/mux-e2e-lpheal-state-$$"
662 defer_rm "$LPHSTATE"
663 LPHWALL="$LPHSTATE/mux/wall"
664 LPHLAYOUT="$LPHSTATE/mux/layout"
665 start_daemon "$SOCK60" "$OUT.lph.d" "layout-heal daemon never bound" --shell /bin/sh
666 D61PID=$DPID
667
668 pipe_mux "$OUT.lpha" "$OUT.lpha.err" timeout 40 "$MUX" --sock "$SOCK60" --session a
669 pipe_send 'printf "lph-%%s\\n" survivor-a\n'
670 await_out "$OUT.lpha" "lph-survivor-a" "layout-heal: session a marker never reached the client"
671 pipe_detach
672 wait_grid "$SOCK60" "lph-survivor-a" "layout-heal: session a marker" a
673 pipe_mux "$OUT.lphb" "$OUT.lphb.err" timeout 40 "$MUX" --sock "$SOCK60" --session b
674 pipe_send 'printf "lph-%%s\\n" removed-b\n'
675 await_out "$OUT.lphb" "lph-removed-b" "layout-heal: session b marker never reached the client"
676 pipe_detach
677 wait_grid "$SOCK60" "lph-removed-b" "layout-heal: session b marker" b
678 pipe_mux "$OUT.lphc" "$OUT.lphc.err" timeout 40 "$MUX" --sock "$SOCK60" --session c
679 pipe_send 'printf "lph-%%s\\n" newcomer-c\n'
680 await_out "$OUT.lphc" "lph-newcomer-c" "layout-heal: session c marker never reached the client"
681 pipe_detach
682 wait_grid "$SOCK60" "lph-newcomer-c" "layout-heal: session c marker" c
683
684 # Wall file starts with a and b. The first ptyclient detaches and saves
685 # a 2-pane sidecar for [a, b].
686 mkdir -p "$LPHSTATE/mux"
687 printf -- '--sock %s#a\n--sock %s#b\n' "$SOCK60" "$SOCK60" > "$LPHWALL"
688
689 set +e
690 XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
691 --out "$OUT.lphcap1" --err "$OUT.lphcap1.err" -- \
692 "$MUX" wall > "$OUT.lphpc1" 2>&1 <<'EOF'
693 expect lph-survivor-a 20000
694 settle 700 20000
695 send \x1cd
696 waitexit 10000
697 EOF
698 RC=$?
699 set -e
700 [ "$RC" -eq 0 ] || {
701 echo "e2e FAIL: layout-heal: first ptyclient exited $RC:"
702 cat "$OUT.lphpc1" "$OUT.lphcap1.err"; exit 1; }
703 [ -f "$LPHLAYOUT" ] || {
704 echo "e2e FAIL: layout-heal: sidecar not saved after first detach"
705 ls -la "$LPHSTATE/mux/" 2>&1; exit 1; }
706
707 # Drift: remove session a's line, add session c's line. The sidecar still
708 # describes [a, b]; the wall file now says [b, c]. On reattach, b is the
709 # survivor (matched), c inserts beside it, a collapses out (unmatched
710 # saved leaf).
711 printf -- '--sock %s#b\n--sock %s#c\n' "$SOCK60" "$SOCK60" > "$LPHWALL"
712
713 set +e
714 XDG_STATE_HOME="$LPHSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
715 --out "$OUT.lphcap2" --err "$OUT.lphcap2.err" -- \
716 "$MUX" wall > "$OUT.lphpc2" 2>&1 <<'EOF'
717 expect lph-removed-b 20000
718 settle 700 20000
719 send printf 'lph-heal-%s\n' marker\n
720 expect lph-heal-marker 10000
721 settle 500 15000
722 send \x1cd
723 waitexit 10000
724 EOF
725 RC=$?
726 set -e
727 [ "$RC" -eq 0 ] || {
728 echo "e2e FAIL: layout-heal: second ptyclient exited $RC:"
729 cat "$OUT.lphpc2" "$OUT.lphcap2.err"; exit 1; }
730 # The wall must not have refused: the ptyclient exited 0 (checked above),
731 # and stderr mentions no layout error. The survivor (b) still reaches its
732 # session — the marker typed after hydration landed in the focused tile,
733 # which is tile 0 (session b, the first wall-file line).
734 grep -qi "layout" "$OUT.lphcap2.err" && {
735 echo "e2e FAIL: layout-heal: stderr mentions layout (heal was not silent):"
736 cat "$OUT.lphcap2.err"; exit 1; }
737 # Two tiles present: the survivor (b) and the newcomer (c). The daemon
738 # has sessions a, b, and c; the wall shows b and c. muxa capture of b
739 # finds the survivor's marker.
740 timeout 20 "$MUXA" capture --sock "$SOCK60" --session b > "$OUT.lphfa" 2>&1
741 grep -q "lph-heal-marker" "$OUT.lphfa" || {
742 echo "e2e FAIL: layout-heal: survivor's marker not in session b:"
743 cat "$OUT.lphfa"; exit 1; }
744 assert_stopped "$SOCK60" "$D61PID" "layout-heal" "$OUT.lphstop"
745 D61PID=""
746 rm -rf "$LPHSTATE"
747 ok "the layout heals on wall-file drift: survivor keeps its session, newcomer joins"
748
749 # ---- layout sidecar: silent degrade on garbage -----------------------
750 #
751 # A corrupted sidecar (`bogus 9`) is not a fatal error: restoreLayout
752 # returns null, the default tree is built, and the wall comes up. The
753 # assertion is that a marker typed into the wall reaches the focused
754 # session (the wall works), and the client's stderr never mentions
755 # "layout" (the degrade is silent).
756 LPDSTATE="${TMPDIR:-/tmp}/mux-e2e-lpdegrade-state-$$"
757 defer_rm "$LPDSTATE"
758 LPDWALL="$LPDSTATE/mux/wall"
759 LPDLAYOUT="$LPDSTATE/mux/layout"
760 start_daemon "$SOCK61" "$OUT.lpd.d" "layout-degrade daemon never bound" --shell /bin/sh
761 D62PID=$DPID
762
763 pipe_mux "$OUT.lpda" "$OUT.lpda.err" timeout 40 "$MUX" --sock "$SOCK61" --session a
764 pipe_send 'printf "lpd-%%s\\n" origin\n'
765 await_out "$OUT.lpda" "lpd-origin" "layout-degrade: session a marker never reached the client"
766 pipe_detach
767 wait_grid "$SOCK61" "lpd-origin" "layout-degrade: session a marker" a
768
769 mkdir -p "$LPDSTATE/mux"
770 printf -- '--sock %s#a\n' "$SOCK61" > "$LPDWALL"
771 # A garbage sidecar: wrong magic, wrong version. restoreLayout returns
772 # null on the header check and the caller builds the default tree.
773 printf 'bogus 9\n' > "$LPDLAYOUT"
774
775 set +e
776 XDG_STATE_HOME="$LPDSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
777 --out "$OUT.lpdcap" --err "$OUT.lpdcap.err" -- \
778 "$MUX" wall > "$OUT.lpdpc" 2>&1 <<'EOF'
779 expect lpd-origin 20000
780 settle 700 20000
781 send printf 'lpd-degrade-%s\n' marker\n
782 expect lpd-degrade-marker 10000
783 settle 500 15000
784 send \x1cd
785 waitexit 10000
786 EOF
787 RC=$?
788 set -e
789 [ "$RC" -eq 0 ] || {
790 echo "e2e FAIL: layout-degrade: ptyclient exited $RC:"
791 cat "$OUT.lpdpc" "$OUT.lpdcap.err"; exit 1; }
792 # The wall came up: the marker reached the focused session.
793 timeout 20 "$MUXA" capture --sock "$SOCK61" --session a > "$OUT.lpdfa" 2>&1
794 grep -q "lpd-degrade-marker" "$OUT.lpdfa" || {
795 echo "e2e FAIL: layout-degrade: marker not in session a (wall did not come up):"
796 cat "$OUT.lpdfa"; exit 1; }
797 # The degrade was silent: no mention of "layout" on the client's stderr.
798 grep -qi "layout" "$OUT.lpdcap.err" && {
799 echo "e2e FAIL: layout-degrade: stderr mentions layout (degrade was not silent):"
800 cat "$OUT.lpdcap.err"; exit 1; }
801 assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop"
802 D62PID=""
803 rm -rf "$LPDSTATE"
804 ok "a corrupted sidecar degrades silently to the default layout"