a73x

d367443a

test: 03_side moves to a file of its own

a73x   2026-08-26 18:49

Commit message
test: 03_side moves to a file of its own

attach auto-start, `muxd stop`, and the four side channels — 7 scenarios, 570 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,8 +102,13 @@ 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' 105 E2E_GROUPS='01_boot 02_predict 03_side'
106 E2E_NEEDS="" 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
108 # missing does not fail, it passes having tested something else — and a
109 # check that passes without running is the one failure a green tree cannot
110 # show.
111 E2E_NEEDS='03_side:01_boot'
107 112
108 # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in 113 # E2E_ONLY=<group> — run one group file and stop. For the loop a leg is in
109 # while it is being written; the gate is still the whole suite, which is 114 # while it is being written; the gate is still the whole suite, which is
@@ -140,573 +145,6 @@ for _g in $E2E_GROUPS; do
140 . "$E2E_DIR/e2e_$_g.sh" 145 . "$E2E_DIR/e2e_$_g.sh"
141 done 146 done
142 147
143 # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the
144 # script is mid-flight — so its pid is tracked and killed like a daemon's.
145 # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY
146 # the proxy, the pty leg's by local mux under the M12 fixture. Both are
147 # torn down by `muxd stop` — the verb under test is also the cleanup, and
148 # the trap only backstops it. Neither daemon is a child of this shell, so
149 # each pid is read off the up-line its own spawner printed, which is the
150 # only handle that can never name a bystander.
151 SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock"
152 defer_sock "$SOCK14"
153 SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock"
154 defer_sock "$SOCK15"
155 # The bell leg's own daemon. A socket of its own is load-bearing rather than
156 # tidiness, and the argument is at the leg itself: that scenario counts BEL
157 # BYTES in a host capture, and a BEL is also what terminates an OSC. $SOCK's
158 # session has a window title set on it by the title leg, and every client
159 # attaching there is told so as `ESC]0;...BEL` — which would land in the
160 # capture and make the count assert something other than what it says.
161 SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock"
162 defer_sock "$SOCK24"
163
164 # --- M13: attach auto-start (proxy) + muxd stop ------------------------
165 #
166 # Nothing is serving SOCK14: the attach itself must produce the daemon.
167 # SHELL pinned because the auto-started daemon gets no --shell flag and
168 # resolves $SHELL — the suite must not inherit the developer's.
169 pipe_mux "$OUT.as" "$OUT.as.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14"
170 pipe_send 'printf "auto-%%s\\n" start\n'
171 await_out "$OUT.as" "auto-start" "proxy auto-start: marker never reached the client"
172 pipe_detach
173 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-start" || {
174 echo "e2e FAIL: proxy auto-start: attached daemon lost the marker"
175 cat "$OUT.as" "$OUT.as.err" 2>/dev/null; exit 1; }
176 grep -q '^muxd proxy: starting' "$OUT.as.err" || {
177 echo "e2e FAIL: cold attach printed no starting line"; cat "$OUT.as.err"; exit 1; }
178 # The pid is the whole point of the up-line: it is the only handle the
179 # suite has on a daemon that is nobody's child. Known gap, accepted: the
180 # two assertions above exit before this capture runs, so a failure in
181 # either leaves a daemon with no pid tracked — and hoisting the capture
182 # would not close it, since the window from the spawn to this sed cannot
183 # be made zero. Unlike the M10 start block's version of this gap (:1150)
184 # it is covered anyway: the trap also stops SOCK14/SOCK15 by PATH, which
185 # needs no pid.
186 APID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.as.err" | head -1)
187 defer_kill "$APID"
188 [ -n "$APID" ] || { echo "e2e FAIL: proxy up-line carries no pid"; cat "$OUT.as.err"; exit 1; }
189 kill -0 "$APID" || { echo "e2e FAIL: auto-started daemon not alive"; exit 1; }
190 assert_converged "$OUT.as" "$SOCK14" "auto-start via proxy"
191
192 # Silence is the fast path: a warm attach must print NO spawn progress.
193 pipe_mux "$OUT.as2" "$OUT.as2.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14"
194 pipe_send 'printf "auto-%%s\\n" again\n'
195 await_out "$OUT.as2" "auto-again" "warm attach: marker never reached the client"
196 pipe_detach
197 grep -q 'starting' "$OUT.as2.err" && {
198 echo "e2e FAIL: warm attach printed spawn progress"
199 cat "$OUT.as2.err"; exit 1; }
200 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-again" || {
201 echo "e2e FAIL: warm attach did not reach the same daemon"; exit 1; }
202 # The same daemon, asserted by pid rather than by the marker alone: a
203 # second daemon on the path would have started a fresh shell, but a
204 # reader should not have to reason that out from a missing string.
205 kill -0 "$APID" || {
206 echo "e2e FAIL: warm attach replaced the daemon (pid $APID is gone)"
207 cat "$OUT.as2.err"; exit 1; }
208 assert_converged "$OUT.as2" "$SOCK14" "auto-start warm attach"
209
210 # The verb under test is the teardown: exit 0, stopped line, socket gone,
211 # and the daemon OBSERVED dead by pid — never the command's claim alone.
212 #
213 # The exit code is checked through `set +e` rather than left to `set -e`,
214 # and that is the difference between a caught regression and a mystery: a
215 # stop that fails exits 1, which would abort the suite with no line of its
216 # own, and the trap would then rm the one file saying why. The regrade
217 # resurrects exactly that failure, so it has to PRINT.
218 set +e
219 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop"
220 RC_STOP=$?
221 set -e
222 [ "$RC_STOP" = "0" ] || {
223 echo "e2e FAIL: stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
224 grep -q '^muxd: stopped' "$OUT.stop" || {
225 echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; }
226 [ ! -S "$SOCK14" ] || {
227 echo "e2e FAIL: stop left the socket behind"; ls -l "$SOCK14"; exit 1; }
228 wait_pid_gone "$APID" "proxy auto-start: stop reported stopped"
229 APID=""
230
231 # Idempotence control: stop with nothing there is exit 0 and says so.
232 set +e
233 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop2"
234 RC_STOP=$?
235 set -e
236 [ "$RC_STOP" = "0" ] || {
237 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
238 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
239 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
240 rm_swept "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" "$OUT.stop" "$OUT.stop2"
241 ok "attach auto-start via proxy; muxd stop tears it down"
242
243 # --- M13: local mux auto-start, under the pty fixture ------------------
244 #
245 # A local attach is tty-gated territory, so it runs on the M12 fixture.
246 # PATH is prefixed with the build dir: findInPath must resolve exactly
247 # the muxd under test, and the suite proves the resolution by the daemon
248 # existing afterwards.
249 #
250 # The script keeps the two M12 rules that every pty leg is written to:
251 # it opens on the alt-screen enter rather than on a `send`. The client
252 # takes the tty raw with TCSAFLUSH (client.zig), which DISCARDS input
253 # queued before it got there — and here that window is a whole cold
254 # spawn wide, so typing into it is typing into /dev/null;
255 # the marker text never appears in the line that produces it, and the
256 # detach waits on `settle` rather than on the marker. A shell writes its
257 # output and its next prompt as two separate writes, so a script that
258 # detaches on the output stops one frame short of the grid the daemon
259 # holds, and the convergence check below would blame the product for it.
260 set +e
261 SHELL=/bin/sh PATH="$(dirname "$MUXD"):$PATH" timeout 40 \
262 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pa" --err "$OUT.pa.err" \
263 -- "$MUX" --sock "$SOCK15" > "$OUT.pa.log" 2>&1 <<'EOF'
264 expect \x1b[?1049h 15000
265 send printf 'pty-%s\\n' auto\n
266 expect pty-auto 15000
267 settle 500 15000
268 send \x1c\x1c
269 waitexit 10000
270 EOF
271 RC=$?
272 set -e
273 [ "$RC" -eq 0 ] || {
274 echo "e2e FAIL: local auto-start ptyclient exited $RC:"
275 cat "$OUT.pa.log"; cat -v "$OUT.pa.err" 2>/dev/null; exit 1; }
276 "$MUXD" dump --sock "$SOCK15" | grep -q "pty-auto" || {
277 echo "e2e FAIL: local auto-start lost the marker"
278 cat "$OUT.pa.log" "$OUT.pa.err" 2>/dev/null; exit 1; }
279 # The fixture hands the client a FILE for stderr, so the progress lines
280 # are non-tty ones and land here rather than on the pty — which is also
281 # why the capture the convergence check reads is undisturbed by them.
282 grep -q '^mux: starting' "$OUT.pa.err" || {
283 echo "e2e FAIL: local auto-start printed no mux-prefixed starting line"
284 cat "$OUT.pa.err"; exit 1; }
285 PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1)
286 defer_kill "$PAPID"
287 [ -n "$PAPID" ] || { echo "e2e FAIL: mux up-line carries no pid"; cat "$OUT.pa.err"; exit 1; }
288 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
289 # Same teardown, same reasons — and the stderr is captured rather than
290 # discarded, so this leg pins the stopped line too. $OUT.stop is reused
291 # deliberately: the proxy arc removed it above, and it is in the trap's
292 # rm list either way.
293 set +e
294 "$MUXD" stop --sock "$SOCK15" 2> "$OUT.stop"
295 RC_STOP=$?
296 set -e
297 [ "$RC_STOP" = "0" ] || {
298 echo "e2e FAIL: pty leg stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
299 grep -q '^muxd: stopped' "$OUT.stop" || {
300 echo "e2e FAIL: pty leg stop did not report stopped"; cat "$OUT.stop"; exit 1; }
301 [ ! -S "$SOCK15" ] || {
302 echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; }
303 wait_pid_gone "$PAPID" "pty leg: stop reported stopped"
304 PAPID=""
305 rm_swept "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop"
306 ok "local mux auto-start under the pty fixture"
307
308 # --- side channel: an OSC 52 written by the session reaches the HOST tty --
309 #
310 # Measured ABSENT on 2026-08-15 (decisions.md, that date): a session emitted
311 # this exact escape, and the bytes the client wrote to its host terminal
312 # carried one \x1b[?1049h, one \x1b[?2026h, one marker — and zero of the
313 # clipboard escape. The daemon's engine had parsed all of it; a client that
314 # paints a grid forwards nothing that is not grid state. This is the same
315 # instrument (the fixture's capture is what a host terminal would have
316 # received) taking the same reading with the path built, and it is the first
317 # end-to-end evidence that any of it does anything.
318 #
319 # The payload is emitted by a FILE the session runs, never typed. The shell
320 # echoes what is typed, so a needle that could arrive as an echo would pass
321 # on a client that forwards nothing at all — the M12 rule (tp2), and here it
322 # is the difference between an assertion and a decoration. The typed line
323 # names the script and cannot spell the escape.
324 cat > "$OUT.clip.sh" <<'CLIPSH'
325 printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007'
326 printf 'CLIPDONE\n'
327 CLIPSH
328 # Unquoted heredoc, unlike every other pty script in this file: the send
329 # verb has to carry $OUT's per-run path. Nothing else in it needs expanding
330 # — `\n` and `\x1c` are not escapes the shell touches.
331 set +e
332 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.clip" --err "$OUT.clip.err" \
333 -- "$MUX" --sock "$SOCK" > "$OUT.clip.log" 2>&1 <<EOF
334 expect \x1b[?1049h 15000
335 settle 400 15000
336 send sh $OUT.clip.sh\n
337 expect CLIPDONE 15000
338 settle 400 15000
339 send \x1c\x1c
340 waitexit 10000
341 EOF
342 RC=$?
343 set -e
344 [ "$RC" -eq 0 ] || {
345 echo "e2e FAIL: clipboard scenario did not run: ptyclient exited $RC"
346 cat "$OUT.clip.log"; cat -v "$OUT.clip.err" 2>/dev/null; exit 1; }
347 # The positive control, and it is not redundant with the check below: the
348 # marker travels the ordinary grid path, so a capture holding it is a
349 # capture of a session that RAN. Without it, "no escape" and "no session"
350 # are the same failure line.
351 grep -qa 'CLIPDONE' "$OUT.clip" || {
352 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
353 # Terminator included: appendHostEffect builds into writeSideChannel's private
354 # buffer, which is written whole or not at all; a needle stopping at the payload
355 # could not tell a complete escape from half of one left painting on the
356 # user's tty. -F because the needle is bytes, not a pattern.
357 CLIPESC=$(printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007')
358 grep -qaF "$CLIPESC" "$OUT.clip" || {
359 echo "e2e FAIL: OSC 52 never reached the host tty"; exit 1; }
360 rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh"
361 ok "the session's OSC 52 reaches the host terminal"
362
363 # --- side channel: the session's window title reaches the host tty -------
364 #
365 # The session sets its title with OSC **2** and the assertion below looks
366 # for OSC **0**. That asymmetry is the instrument: a client that forwarded
367 # session bytes would put back exactly what went in, so a capture holding
368 # `ESC]0;` for a session that only ever wrote `ESC]2;` can only be the
369 # client re-rendering sampled state (client.zig, appendTermTitle). Both
370 # forms reach the engine as one window-title operation, which is why the
371 # session is free to pick the one mux does not emit.
372 #
373 # Emitted by a FILE, never typed, for the M12 reason spelled out on the
374 # clipboard leg above: the shell echoes what is typed, so a needle that
375 # could arrive as an echo would pass on a client that forwards nothing.
376 #
377 # The `?2004h` is not this leg's business — it is the NEXT leg's control.
378 # It leaves the session armed for bracketed paste as well as titled, so
379 # that when the non-tty client below attaches to this same daemon there are
380 # two side channels the daemon is definitely emitting, and "neither
381 # arrived" cannot be confused with "neither was sent".
382 cat > "$OUT.title.sh" <<'TITLESH'
383 printf '\033]2;mux-e2e-title\007'
384 printf '\033[?2004h'
385 printf 'TITLEDONE\n'
386 TITLESH
387 set +e
388 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.title" --err "$OUT.title.err" \
389 -- "$MUX" --sock "$SOCK" > "$OUT.title.log" 2>&1 <<EOF
390 expect \x1b[?1049h 15000
391 settle 400 15000
392 send sh $OUT.title.sh\n
393 expect TITLEDONE 15000
394 settle 400 15000
395 send \x1c\x1c
396 waitexit 10000
397 EOF
398 RC=$?
399 set -e
400 [ "$RC" -eq 0 ] || {
401 echo "e2e FAIL: title scenario did not run: ptyclient exited $RC"
402 cat "$OUT.title.log"; cat -v "$OUT.title.err" 2>/dev/null; exit 1; }
403 # Positive control: the marker travels the ordinary grid path, so without it
404 # "no title" and "no session" would be the same failure line.
405 grep -qa 'TITLEDONE' "$OUT.title" || {
406 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
407 # Terminator included, for the same reason as the clipboard needle: the
408 # builder writes the escape whole or writes nothing, and a needle stopping
409 # at the payload could not tell one from half of one.
410 # OSC **0**, deliberately, against a fixture that wrote OSC **2** — the
411 # asymmetry is the instrument, and it is argued at the head of this leg.
412 # Do not "fix" either side to match the other without reading that.
413 TITLEESC=$(printf '\033]0;mux-e2e-title\007')
414 grep -qaF "$TITLEESC" "$OUT.title" || {
415 echo "e2e FAIL: the window title never reached the host tty"; exit 1; }
416 # The restore pair, asserted by ORDER and not merely by presence. Presence
417 # alone would pass on a client that popped before it pushed, or pushed twice
418 # and popped once — and an unmatched pop does not restore a title, it pops
419 # whatever the terminal had underneath, which is somebody else's. The
420 # offsets say: pushed before mux set anything, popped after.
421 first_offset() {
422 grep -aboF "$2" "$1" | head -1 | cut -d: -f1
423 }
424 occurrences() {
425 grep -aoF "$2" "$1" | wc -l
426 }
427 PUSH_ESC=$(printf '\033[22;0t')
428 POP_ESC=$(printf '\033[23;0t')
429 PUSH_AT=$(first_offset "$OUT.title" "$PUSH_ESC")
430 POP_AT=$(first_offset "$OUT.title" "$POP_ESC")
431 SET_AT=$(first_offset "$OUT.title" "$TITLEESC")
432 [ -n "$PUSH_AT" ] || { echo "e2e FAIL: no title push on the host tty"; exit 1; }
433 [ -n "$POP_AT" ] || { echo "e2e FAIL: no title pop on the host tty (exit left it set)"; exit 1; }
434 [ "$PUSH_AT" -lt "$SET_AT" ] || {
435 echo "e2e FAIL: mux set the title at $SET_AT before pushing at $PUSH_AT"; exit 1; }
436 [ "$SET_AT" -lt "$POP_AT" ] || {
437 echo "e2e FAIL: the title pop at $POP_AT came before the set at $SET_AT"; exit 1; }
438 # Exactly one of each, which order alone does not catch. A second push is a
439 # stack this run leaves one deeper than it found it, and the argument that
440 # it cannot happen — the reconnect path re-enters with alt_screen already
441 # true (client.zig) — was prose with no test behind it until here.
442 PUSH_N=$(occurrences "$OUT.title" "$PUSH_ESC")
443 POP_N=$(occurrences "$OUT.title" "$POP_ESC")
444 [ "$PUSH_N" -eq 1 ] || {
445 echo "e2e FAIL: $PUSH_N title pushes on one run, want exactly 1"; exit 1; }
446 [ "$POP_N" -eq 1 ] || {
447 echo "e2e FAIL: $POP_N title pops on one run, want exactly 1"; exit 1; }
448 # The control for the leg that follows, asserted on THIS capture because
449 # this is the run that had a pty: the daemon emits both side channels for
450 # this session, right now. Without it, "neither reached the non-tty client"
451 # would pass just as well on a daemon that had stopped sending either.
452 grep -qaF "$(printf '\033[?2004h')" "$OUT.title" || {
453 echo "e2e FAIL: the session armed bracketed paste but the tty client never heard"
454 cat -v "$OUT.title"; exit 1; }
455 rm_swept "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh"
456 ok "the window title reaches the host as OSC 0, pushed before and popped after"
457
458 # --- side channel: no terminal taken over, no terminal state changed -----
459 #
460 # The bug this pins shipped and was fixed in c18c54b. `is_tty` is isatty of
461 # STDIN — it gates raw mode and the alt-screen entry, both input concerns —
462 # while the side-channel arms write to STDOUT. A client with stdin
463 # redirected therefore never set `alt_screen`, never ran the teardown, and
464 # yet happily wrote the session's title and `?2004h` to whatever stdout
465 # was. Measured on the parent commit, against this same daemon: the capture
466 # ended `...ESC[?2004h ESC]0;mux-e2e-title BEL` — the title set and the
467 # paste mode set as the final bytes, and nothing anywhere to undo either.
468 #
469 # Same session as the leg above, which is what makes it meaningful: that
470 # leg just proved with a pty that the daemon is sending both of these.
471 #
472 # stdin from /dev/null and stdout to a FILE, so mux takes nothing over.
473 timeout 4 "$MUX" --sock "$SOCK" < /dev/null > "$OUT.nogate" 2>&1 || true
474 # Positive control first: this capture must be of a client that ATTACHED
475 # and painted, or every negative below it is vacuous. TITLEDONE is on that
476 # session's screen from the leg above, so it arrives by the ordinary grid
477 # path — the path that is supposed to keep working here.
478 grep -qaF "TITLEDONE" "$OUT.nogate" || {
479 echo "e2e FAIL: the non-tty client never painted the session (nothing to conclude)"
480 cat -v "$OUT.nogate"; exit 1; }
481 # -F on every needle, for the reason documented at the dead-transport leg:
482 # without it these are malformed bracket expressions, grep exits 2, and
483 # `grep && { fail }` reads an error as "not found".
484 #
485 # `ESC]0;` is the bare prefix rather than the full title: ANY title write is
486 # the failure here, not just that one session's.
487 for NEEDLE in "$(printf '\033]0;')" "$(printf '\033[?2004h')" "$(printf '\033[22;0t')"; do
488 grep -qaF "$NEEDLE" "$OUT.nogate" && {
489 echo "e2e FAIL: side channel written to a terminal mux never took over"
490 cat -v "$OUT.nogate"; exit 1; }
491 done
492 rm_swept "$OUT.nogate"
493 ok "a client that takes no terminal over changes no terminal state"
494
495 # --- side channel: a paste into a real editor keeps its indentation ------
496 #
497 # The byte-level pin (?2004h in a host capture) proves the FRAME arrived;
498 # this proves pasting WORKS. Measured on 2026-08-15 before the fix: the
499 # third line of this exact block came out with 8 spaces instead of 4 — the
500 # classic autoindent staircase — because the host terminal was never told
501 # the application had asked for bracketed paste, and the host terminal is
502 # the only thing that can actually bracket one.
503 #
504 # ptyclient's `paste` verb brackets only when it has SEEN ?2004h go past on
505 # the capture, which is what a real terminal does and is why this scenario
506 # cannot pass on a mirror that stopped working: with the mirror neutered the
507 # same bytes arrive unbracketed, nvim autoindents them, and the file below
508 # holds the staircase. The assertion is that file, not an escape — a grep
509 # for the escape would go on passing while paste itself broke.
510 #
511 # nvim is REQUIRED, not guarded with `command -v`. A guard would let a box
512 # without nvim skip this quietly, and a silently-skipped scenario is exactly
513 # what the OK_COUNT pin at the bottom exists to catch — so the dependency
514 # fails loudly. The check itself is HOISTED to the head of this file, next to
515 # the argument binding: a box without nvim should hear about it before it
516 # spends minutes getting here.
517 #
518 # That reverses M9's standing rule (2026-08-08-m9-prediction.md: "rawmode
519 # helper, not nvim, in CI"), which gave two reasons. Its PRESENCE half is
520 # answered by the check above — refusing to run beats running less. Its
521 # NONDETERMINISM half — nvim's redraw timing — is answered by construction:
522 # every wait below is on a CONDITION (a needle that must arrive) or on
523 # SILENCE (settle), and neither is a bet on when a redraw happens. If this
524 # scenario ever flakes, that is the property to check first; a wait added
525 # here that keys off redraw timing would be the regression.
526 #
527 # Unquoted heredoc, like the clipboard leg above: the send verb carries
528 # $OUT's per-run path. `\x1b`, `\r`, `\n` and `\x1c` are the fixture's
529 # escapes and the shell leaves every one of them alone.
530 #
531 # -i NONE keeps nvim off the operator's shada file; -u NONE keeps it off
532 # their config, so 'autoindent' is set here and nowhere else — the staircase
533 # this measures has to come from the option this line spells.
534 #
535 # `timeout 60` is under the heredoc's own deadlines (120s if every one of
536 # them ran long), and that is deliberate rather than copied from the leg
537 # above: those deadlines are per-verb ceilings for a fixture that is still
538 # talking, and a run that genuinely needs more than 60s wall has something
539 # wrong with it that a bare `exited 124` describes about as well as a
540 # fixture dump would. The cost is real — 124 loses the "last N bytes
541 # received" line — so anything raised here should raise this too.
542 set +e
543 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \
544 -- "$MUX" --sock "$SOCK" > "$OUT.paste.log" 2>&1 <<EOF
545 expect \x1b[?1049h 15000
546 settle 400 15000
547 send nvim -u NONE -i NONE -c "set autoindent" -c startinsert $OUT.pasted.txt\n
548 # The mode indicator, whole. The line above echoes "startinsert", so a bare
549 # INSERT needle would differ from the echo by capitalisation alone (the M12
550 # tp2 rule passing on a technicality); the dashes are what that echo cannot
551 # spell.
552 #
553 # And NOT an expect for the 2004h escape, deliberately, however much this
554 # reads like the place for one. The fixture watches for that escape without
555 # waiting on it: it brackets a paste when it has seen one and sends bare
556 # bytes when it has not. A needle for it here would wait, and that changes
557 # what a broken mirror looks like — instead of the file below holding the
558 # staircase, the run would time out on the needle and never reach the
559 # assertion at all. The escape ARRIVING is already pinned, in client.zig's
560 # unit tests. What THIS leg measures is pasting working, and it can only
561 # measure that if the escape's absence is allowed through to the editor.
562 expect -- INSERT -- 20000
563 settle 500 20000
564 paste if x:\r a = 1\r b = 2\r
565 settle 500 20000
566 # Two sends, not one. Delivered in the same read as the colon, nvim's input
567 # parser is entitled to read the ESC as the Alt- prefix of Alt-colon rather
568 # than as leaving insert mode, and the file would never be written. The
569 # settle between them is not a timer for a repaint: what the split needs is
570 # the ESC and the colon landing in different reads, and this verb cannot
571 # return in under its quiet window whatever the screen does, which is that
572 # separation — by far outlasting the 50ms ttimeoutlen the ambiguity turns
573 # on. (settle proves silence, never arrival; see its docstring.)
574 send \x1b
575 settle 300 15000
576 send :wq\r
577 # What makes the file safe to read after this is the ORDERING, not the
578 # number: nvim writes the buffer before it exits, so everything that follows
579 # on the master — its exit repaint, then the shell's prompt — comes after
580 # the write, and silence measured past all of that cannot precede it. The
581 # 800 is slack on top of a guarantee, not a measurement of how long nvim
582 # takes.
583 settle 800 20000
584 send \x1c\x1c
585 waitexit 10000
586 EOF
587 RC=$?
588 set -e
589 [ "$RC" -eq 0 ] || {
590 echo "e2e FAIL: paste scenario did not run: ptyclient exited $RC"
591 cat "$OUT.paste.log"; cat -v "$OUT.paste.err" 2>/dev/null; exit 1; }
592 # Separate from the indentation check on purpose: "nvim never wrote" and
593 # "nvim wrote the wrong thing" are different failures, and one message for
594 # both would send the reader looking at the wrong half.
595 [ -s "$OUT.pasted.txt" ] || {
596 echo "e2e FAIL: nvim wrote no file — the paste never reached the editor"
597 cat "$OUT.paste.log"; exit 1; }
598 grep -q '^ b = 2$' "$OUT.pasted.txt" || {
599 echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)"
600 cat -A "$OUT.pasted.txt"; exit 1; }
601 rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
602 ok "a paste into nvim keeps its indentation"
603
604 # --- side channel: a burst of bells reaches the host as exactly one ------
605 #
606 # This leg exists for the COALESCING, not for the bell. The shared client core
607 # decodes every term_event, then every accepted effect follows the same
608 # writeSideChannel → appendHostEffect path, so the clipboard leg above already
609 # proves frame → decode → adapter → host tty for the whole frame type. A bell
610 # that merely ARRIVED would add no coverage: the bell-specific adapter line is
611 # pinned directly in client.zig. What only e2e can see is the NUMBER. Five rings
612 # inside one pty chunk must reach
613 # the host as one (server.zig, drainSideEvents), and a regression there is
614 # silent — every assertion anyone would think to write about a bell still
615 # passes while 256 frames per chunk go out.
616 #
617 # So the assertion is a count, and a count of BEL BYTES is only unambiguous
618 # if nothing else in the capture can contribute one. Two things make that
619 # true here, and both are asserted rather than assumed:
620 #
621 # 1. A daemon of its own. The client writes exactly two OSCs, ever —
622 # `ESC]52;` and `ESC]0;` (client.zig), each BEL-terminated — and $SOCK's
623 # session has had a title set on it by the leg above, which every client
624 # attaching there is then told about. On a fresh daemon nothing sets a
625 # title and nothing copies, so neither OSC has an occasion to be built.
626 # 2. The zero-OSC check below, which is what turns that argument into a
627 # measurement. With no `ESC]` anywhere in the capture, no BEL in it can
628 # be terminating a sequence.
629 #
630 # That leaves one way a BEL could arrive with no `ESC]` in front of it, which
631 # check 2 would NOT catch: as painted cell content, since paint.zig writes
632 # row.bytes verbatim. It cannot arise here — ghostty-vt executes C0 rather
633 # than storing it, and this session paints a prompt, an echo and BELLDONE —
634 # but the two checks above are what make the count unambiguous, not a proof
635 # that no other path to a BEL exists.
636 start_daemon "$SOCK24" "$OUT.d21.d" "bell daemon socket never appeared" --shell /bin/sh
637 D21PID=$DPID
638
639 # Five rings in ONE printf, which is one write and so in practice one pty read
640 # and one drain — the line discipline is not going to split five bytes short of
641 # a full output buffer, but that is a property of the read side rather than
642 # anything the single write guarantees. Split across two writes they would be
643 # two chunks and two frames, correctly, since coalescing is per drain; the
644 # single printf is what makes "one" the right answer here. A split would show
645 # up as `2 bells ... want exactly 1`, so read that as a chunking accident
646 # before reading it as lost coalescing.
647 #
648 # Emitted by a FILE, never typed, for the M12 reason spelled out on the
649 # clipboard leg: the shell echoes what is typed, so a needle that could
650 # arrive as an echo would pass on a client that forwards nothing at all.
651 cat > "$OUT.bell.sh" <<'BELLSH'
652 printf '\007\007\007\007\007'
653 printf 'BELLDONE\n'
654 BELLSH
655 set +e
656 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.bell" --err "$OUT.bell.err" \
657 -- "$MUX" --sock "$SOCK24" > "$OUT.bell.log" 2>&1 <<EOF
658 expect \x1b[?1049h 15000
659 settle 400 15000
660 send sh $OUT.bell.sh\n
661 expect BELLDONE 15000
662 settle 400 15000
663 send \x1c\x1c
664 waitexit 10000
665 EOF
666 RC=$?
667 set -e
668 [ "$RC" -eq 0 ] || {
669 echo "e2e FAIL: bell scenario did not run: ptyclient exited $RC"
670 cat "$OUT.bell.log"; cat -v "$OUT.bell.err" 2>/dev/null; exit 1; }
671 # The positive control, as on every leg here: the marker travels the ordinary
672 # grid path, so without it "one bell" and "no session" are the same reading —
673 # and "zero bells" would pass the zero-OSC check below just as well.
674 grep -qa 'BELLDONE' "$OUT.bell" || {
675 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
676 # -F, and it is not decoration: the needle is `ESC]`, and `]` unescaped makes
677 # this a malformed bracket expression — grep exits 2 and `grep && { fail }`
678 # reads the error as "not found". That exact defect has already shipped in
679 # this file once (see the dead-transport leg).
680 OSCINTRO=$(printf '\033]')
681 grep -qaF "$OSCINTRO" "$OUT.bell" && {
682 echo "e2e FAIL: an OSC reached the bell capture, so its BELs cannot be counted"
683 cat -v "$OUT.bell"; exit 1; }
684 # ...and now the count means what it says. `occurrences` is the title leg's
685 # helper, defined above and reused here on the same kind of byte needle.
686 BELBYTE=$(printf '\007')
687 BELLN=$(occurrences "$OUT.bell" "$BELBYTE")
688 [ "$BELLN" -eq 1 ] || {
689 echo "e2e FAIL: $BELLN bells on the host tty for one burst of five, want exactly 1"
690 cat -v "$OUT.bell"; exit 1; }
691 "$MUXD" stop --sock "$SOCK24" > /dev/null 2>&1 || true
692 wait_pid_gone "$D21PID" "bell leg: stop reported stopped"
693 D21PID=""
694 rm_swept "$OUT.bell" "$OUT.bell.err" "$OUT.bell.log" "$OUT.bell.sh" "$OUT.d21.d"
695 ok "a burst of bells in one chunk reaches the host as exactly one"
696
697 # The long-lived daemon has served every scenario that wanted it; stop it
698 # NOW so its allocator verdict is written while the suite is still running
699 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
700 # (and the verdict) runs.
701 #
702 # The sweep that READS that verdict is in the EXIT trap, not here: this line
703 # is only reached by a run that passed, and a leak deserves reporting on the
704 # runs that did not (see leak_sweep). Everything below it is a pin, and the
705 # trap fires after all of them either way.
706 softkill "$D1PID" || true
707 wait "$D1PID" 2>/dev/null || true
708 D1PID=""
709
710 # M14 ssh→QUIC handoff. The shim that stands in for ssh (built where the 148 # M14 ssh→QUIC handoff. The shim that stands in for ssh (built where the
711 # scenarios run), and a RUNTIME DIR 149 # scenarios run), and a RUNTIME DIR
712 # per daemon: the handoff dials the DEFAULT socket on the far side, and the 150 # per daemon: the handoff dials the DEFAULT socket on the far side, and the
test/e2e_03_side.sh
Old New
@@ -0,0 +1,570 @@
1 # shellcheck shell=sh
2 # e2e_03_side.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 # tp1 runs its fixture in the BACKGROUND — the tear has to happen while the
6 # script is mid-flight — so its pid is tracked and killed like a daemon's.
7 # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY
8 # the proxy, the pty leg's by local mux under the M12 fixture. Both are
9 # torn down by `muxd stop` — the verb under test is also the cleanup, and
10 # the trap only backstops it. Neither daemon is a child of this shell, so
11 # each pid is read off the up-line its own spawner printed, which is the
12 # only handle that can never name a bystander.
13 SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock"
14 defer_sock "$SOCK14"
15 SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock"
16 defer_sock "$SOCK15"
17 # The bell leg's own daemon. A socket of its own is load-bearing rather than
18 # tidiness, and the argument is at the leg itself: that scenario counts BEL
19 # BYTES in a host capture, and a BEL is also what terminates an OSC. $SOCK's
20 # session has a window title set on it by the title leg, and every client
21 # attaching there is told so as `ESC]0;...BEL` — which would land in the
22 # capture and make the count assert something other than what it says.
23 SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock"
24 defer_sock "$SOCK24"
25
26 # --- M13: attach auto-start (proxy) + muxd stop ------------------------
27 #
28 # Nothing is serving SOCK14: the attach itself must produce the daemon.
29 # SHELL pinned because the auto-started daemon gets no --shell flag and
30 # resolves $SHELL — the suite must not inherit the developer's.
31 pipe_mux "$OUT.as" "$OUT.as.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14"
32 pipe_send 'printf "auto-%%s\\n" start\n'
33 await_out "$OUT.as" "auto-start" "proxy auto-start: marker never reached the client"
34 pipe_detach
35 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-start" || {
36 echo "e2e FAIL: proxy auto-start: attached daemon lost the marker"
37 cat "$OUT.as" "$OUT.as.err" 2>/dev/null; exit 1; }
38 grep -q '^muxd proxy: starting' "$OUT.as.err" || {
39 echo "e2e FAIL: cold attach printed no starting line"; cat "$OUT.as.err"; exit 1; }
40 # The pid is the whole point of the up-line: it is the only handle the
41 # suite has on a daemon that is nobody's child. Known gap, accepted: the
42 # two assertions above exit before this capture runs, so a failure in
43 # either leaves a daemon with no pid tracked — and hoisting the capture
44 # would not close it, since the window from the spawn to this sed cannot
45 # be made zero. Unlike the M10 start block's version of this gap (:1150)
46 # it is covered anyway: the trap also stops SOCK14/SOCK15 by PATH, which
47 # needs no pid.
48 APID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.as.err" | head -1)
49 defer_kill "$APID"
50 [ -n "$APID" ] || { echo "e2e FAIL: proxy up-line carries no pid"; cat "$OUT.as.err"; exit 1; }
51 kill -0 "$APID" || { echo "e2e FAIL: auto-started daemon not alive"; exit 1; }
52 assert_converged "$OUT.as" "$SOCK14" "auto-start via proxy"
53
54 # Silence is the fast path: a warm attach must print NO spawn progress.
55 pipe_mux "$OUT.as2" "$OUT.as2.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14"
56 pipe_send 'printf "auto-%%s\\n" again\n'
57 await_out "$OUT.as2" "auto-again" "warm attach: marker never reached the client"
58 pipe_detach
59 grep -q 'starting' "$OUT.as2.err" && {
60 echo "e2e FAIL: warm attach printed spawn progress"
61 cat "$OUT.as2.err"; exit 1; }
62 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-again" || {
63 echo "e2e FAIL: warm attach did not reach the same daemon"; exit 1; }
64 # The same daemon, asserted by pid rather than by the marker alone: a
65 # second daemon on the path would have started a fresh shell, but a
66 # reader should not have to reason that out from a missing string.
67 kill -0 "$APID" || {
68 echo "e2e FAIL: warm attach replaced the daemon (pid $APID is gone)"
69 cat "$OUT.as2.err"; exit 1; }
70 assert_converged "$OUT.as2" "$SOCK14" "auto-start warm attach"
71
72 # The verb under test is the teardown: exit 0, stopped line, socket gone,
73 # and the daemon OBSERVED dead by pid — never the command's claim alone.
74 #
75 # The exit code is checked through `set +e` rather than left to `set -e`,
76 # and that is the difference between a caught regression and a mystery: a
77 # stop that fails exits 1, which would abort the suite with no line of its
78 # own, and the trap would then rm the one file saying why. The regrade
79 # resurrects exactly that failure, so it has to PRINT.
80 set +e
81 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop"
82 RC_STOP=$?
83 set -e
84 [ "$RC_STOP" = "0" ] || {
85 echo "e2e FAIL: stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
86 grep -q '^muxd: stopped' "$OUT.stop" || {
87 echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; }
88 [ ! -S "$SOCK14" ] || {
89 echo "e2e FAIL: stop left the socket behind"; ls -l "$SOCK14"; exit 1; }
90 wait_pid_gone "$APID" "proxy auto-start: stop reported stopped"
91 APID=""
92
93 # Idempotence control: stop with nothing there is exit 0 and says so.
94 set +e
95 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop2"
96 RC_STOP=$?
97 set -e
98 [ "$RC_STOP" = "0" ] || {
99 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
100 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
101 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
102 rm_swept "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" "$OUT.stop" "$OUT.stop2"
103 ok "attach auto-start via proxy; muxd stop tears it down"
104
105 # --- M13: local mux auto-start, under the pty fixture ------------------
106 #
107 # A local attach is tty-gated territory, so it runs on the M12 fixture.
108 # PATH is prefixed with the build dir: findInPath must resolve exactly
109 # the muxd under test, and the suite proves the resolution by the daemon
110 # existing afterwards.
111 #
112 # The script keeps the two M12 rules that every pty leg is written to:
113 # it opens on the alt-screen enter rather than on a `send`. The client
114 # takes the tty raw with TCSAFLUSH (client.zig), which DISCARDS input
115 # queued before it got there — and here that window is a whole cold
116 # spawn wide, so typing into it is typing into /dev/null;
117 # the marker text never appears in the line that produces it, and the
118 # detach waits on `settle` rather than on the marker. A shell writes its
119 # output and its next prompt as two separate writes, so a script that
120 # detaches on the output stops one frame short of the grid the daemon
121 # holds, and the convergence check below would blame the product for it.
122 set +e
123 SHELL=/bin/sh PATH="$(dirname "$MUXD"):$PATH" timeout 40 \
124 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pa" --err "$OUT.pa.err" \
125 -- "$MUX" --sock "$SOCK15" > "$OUT.pa.log" 2>&1 <<'EOF'
126 expect \x1b[?1049h 15000
127 send printf 'pty-%s\\n' auto\n
128 expect pty-auto 15000
129 settle 500 15000
130 send \x1c\x1c
131 waitexit 10000
132 EOF
133 RC=$?
134 set -e
135 [ "$RC" -eq 0 ] || {
136 echo "e2e FAIL: local auto-start ptyclient exited $RC:"
137 cat "$OUT.pa.log"; cat -v "$OUT.pa.err" 2>/dev/null; exit 1; }
138 "$MUXD" dump --sock "$SOCK15" | grep -q "pty-auto" || {
139 echo "e2e FAIL: local auto-start lost the marker"
140 cat "$OUT.pa.log" "$OUT.pa.err" 2>/dev/null; exit 1; }
141 # The fixture hands the client a FILE for stderr, so the progress lines
142 # are non-tty ones and land here rather than on the pty — which is also
143 # why the capture the convergence check reads is undisturbed by them.
144 grep -q '^mux: starting' "$OUT.pa.err" || {
145 echo "e2e FAIL: local auto-start printed no mux-prefixed starting line"
146 cat "$OUT.pa.err"; exit 1; }
147 PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1)
148 defer_kill "$PAPID"
149 [ -n "$PAPID" ] || { echo "e2e FAIL: mux up-line carries no pid"; cat "$OUT.pa.err"; exit 1; }
150 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
151 # Same teardown, same reasons — and the stderr is captured rather than
152 # discarded, so this leg pins the stopped line too. $OUT.stop is reused
153 # deliberately: the proxy arc removed it above, and it is in the trap's
154 # rm list either way.
155 set +e
156 "$MUXD" stop --sock "$SOCK15" 2> "$OUT.stop"
157 RC_STOP=$?
158 set -e
159 [ "$RC_STOP" = "0" ] || {
160 echo "e2e FAIL: pty leg stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
161 grep -q '^muxd: stopped' "$OUT.stop" || {
162 echo "e2e FAIL: pty leg stop did not report stopped"; cat "$OUT.stop"; exit 1; }
163 [ ! -S "$SOCK15" ] || {
164 echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; }
165 wait_pid_gone "$PAPID" "pty leg: stop reported stopped"
166 PAPID=""
167 rm_swept "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop"
168 ok "local mux auto-start under the pty fixture"
169
170 # --- side channel: an OSC 52 written by the session reaches the HOST tty --
171 #
172 # Measured ABSENT on 2026-08-15 (decisions.md, that date): a session emitted
173 # this exact escape, and the bytes the client wrote to its host terminal
174 # carried one \x1b[?1049h, one \x1b[?2026h, one marker — and zero of the
175 # clipboard escape. The daemon's engine had parsed all of it; a client that
176 # paints a grid forwards nothing that is not grid state. This is the same
177 # instrument (the fixture's capture is what a host terminal would have
178 # received) taking the same reading with the path built, and it is the first
179 # end-to-end evidence that any of it does anything.
180 #
181 # The payload is emitted by a FILE the session runs, never typed. The shell
182 # echoes what is typed, so a needle that could arrive as an echo would pass
183 # on a client that forwards nothing at all — the M12 rule (tp2), and here it
184 # is the difference between an assertion and a decoration. The typed line
185 # names the script and cannot spell the escape.
186 cat > "$OUT.clip.sh" <<'CLIPSH'
187 printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007'
188 printf 'CLIPDONE\n'
189 CLIPSH
190 # Unquoted heredoc, unlike every other pty script in this file: the send
191 # verb has to carry $OUT's per-run path. Nothing else in it needs expanding
192 # — `\n` and `\x1c` are not escapes the shell touches.
193 set +e
194 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.clip" --err "$OUT.clip.err" \
195 -- "$MUX" --sock "$SOCK" > "$OUT.clip.log" 2>&1 <<EOF
196 expect \x1b[?1049h 15000
197 settle 400 15000
198 send sh $OUT.clip.sh\n
199 expect CLIPDONE 15000
200 settle 400 15000
201 send \x1c\x1c
202 waitexit 10000
203 EOF
204 RC=$?
205 set -e
206 [ "$RC" -eq 0 ] || {
207 echo "e2e FAIL: clipboard scenario did not run: ptyclient exited $RC"
208 cat "$OUT.clip.log"; cat -v "$OUT.clip.err" 2>/dev/null; exit 1; }
209 # The positive control, and it is not redundant with the check below: the
210 # marker travels the ordinary grid path, so a capture holding it is a
211 # capture of a session that RAN. Without it, "no escape" and "no session"
212 # are the same failure line.
213 grep -qa 'CLIPDONE' "$OUT.clip" || {
214 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
215 # Terminator included: appendHostEffect builds into writeSideChannel's private
216 # buffer, which is written whole or not at all; a needle stopping at the payload
217 # could not tell a complete escape from half of one left painting on the
218 # user's tty. -F because the needle is bytes, not a pattern.
219 CLIPESC=$(printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007')
220 grep -qaF "$CLIPESC" "$OUT.clip" || {
221 echo "e2e FAIL: OSC 52 never reached the host tty"; exit 1; }
222 rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh"
223 ok "the session's OSC 52 reaches the host terminal"
224
225 # --- side channel: the session's window title reaches the host tty -------
226 #
227 # The session sets its title with OSC **2** and the assertion below looks
228 # for OSC **0**. That asymmetry is the instrument: a client that forwarded
229 # session bytes would put back exactly what went in, so a capture holding
230 # `ESC]0;` for a session that only ever wrote `ESC]2;` can only be the
231 # client re-rendering sampled state (client.zig, appendTermTitle). Both
232 # forms reach the engine as one window-title operation, which is why the
233 # session is free to pick the one mux does not emit.
234 #
235 # Emitted by a FILE, never typed, for the M12 reason spelled out on the
236 # clipboard leg above: the shell echoes what is typed, so a needle that
237 # could arrive as an echo would pass on a client that forwards nothing.
238 #
239 # The `?2004h` is not this leg's business — it is the NEXT leg's control.
240 # It leaves the session armed for bracketed paste as well as titled, so
241 # that when the non-tty client below attaches to this same daemon there are
242 # two side channels the daemon is definitely emitting, and "neither
243 # arrived" cannot be confused with "neither was sent".
244 cat > "$OUT.title.sh" <<'TITLESH'
245 printf '\033]2;mux-e2e-title\007'
246 printf '\033[?2004h'
247 printf 'TITLEDONE\n'
248 TITLESH
249 set +e
250 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.title" --err "$OUT.title.err" \
251 -- "$MUX" --sock "$SOCK" > "$OUT.title.log" 2>&1 <<EOF
252 expect \x1b[?1049h 15000
253 settle 400 15000
254 send sh $OUT.title.sh\n
255 expect TITLEDONE 15000
256 settle 400 15000
257 send \x1c\x1c
258 waitexit 10000
259 EOF
260 RC=$?
261 set -e
262 [ "$RC" -eq 0 ] || {
263 echo "e2e FAIL: title scenario did not run: ptyclient exited $RC"
264 cat "$OUT.title.log"; cat -v "$OUT.title.err" 2>/dev/null; exit 1; }
265 # Positive control: the marker travels the ordinary grid path, so without it
266 # "no title" and "no session" would be the same failure line.
267 grep -qa 'TITLEDONE' "$OUT.title" || {
268 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
269 # Terminator included, for the same reason as the clipboard needle: the
270 # builder writes the escape whole or writes nothing, and a needle stopping
271 # at the payload could not tell one from half of one.
272 # OSC **0**, deliberately, against a fixture that wrote OSC **2** — the
273 # asymmetry is the instrument, and it is argued at the head of this leg.
274 # Do not "fix" either side to match the other without reading that.
275 TITLEESC=$(printf '\033]0;mux-e2e-title\007')
276 grep -qaF "$TITLEESC" "$OUT.title" || {
277 echo "e2e FAIL: the window title never reached the host tty"; exit 1; }
278 # The restore pair, asserted by ORDER and not merely by presence. Presence
279 # alone would pass on a client that popped before it pushed, or pushed twice
280 # and popped once — and an unmatched pop does not restore a title, it pops
281 # whatever the terminal had underneath, which is somebody else's. The
282 # offsets say: pushed before mux set anything, popped after.
283 first_offset() {
284 grep -aboF "$2" "$1" | head -1 | cut -d: -f1
285 }
286 occurrences() {
287 grep -aoF "$2" "$1" | wc -l
288 }
289 PUSH_ESC=$(printf '\033[22;0t')
290 POP_ESC=$(printf '\033[23;0t')
291 PUSH_AT=$(first_offset "$OUT.title" "$PUSH_ESC")
292 POP_AT=$(first_offset "$OUT.title" "$POP_ESC")
293 SET_AT=$(first_offset "$OUT.title" "$TITLEESC")
294 [ -n "$PUSH_AT" ] || { echo "e2e FAIL: no title push on the host tty"; exit 1; }
295 [ -n "$POP_AT" ] || { echo "e2e FAIL: no title pop on the host tty (exit left it set)"; exit 1; }
296 [ "$PUSH_AT" -lt "$SET_AT" ] || {
297 echo "e2e FAIL: mux set the title at $SET_AT before pushing at $PUSH_AT"; exit 1; }
298 [ "$SET_AT" -lt "$POP_AT" ] || {
299 echo "e2e FAIL: the title pop at $POP_AT came before the set at $SET_AT"; exit 1; }
300 # Exactly one of each, which order alone does not catch. A second push is a
301 # stack this run leaves one deeper than it found it, and the argument that
302 # it cannot happen — the reconnect path re-enters with alt_screen already
303 # true (client.zig) — was prose with no test behind it until here.
304 PUSH_N=$(occurrences "$OUT.title" "$PUSH_ESC")
305 POP_N=$(occurrences "$OUT.title" "$POP_ESC")
306 [ "$PUSH_N" -eq 1 ] || {
307 echo "e2e FAIL: $PUSH_N title pushes on one run, want exactly 1"; exit 1; }
308 [ "$POP_N" -eq 1 ] || {
309 echo "e2e FAIL: $POP_N title pops on one run, want exactly 1"; exit 1; }
310 # The control for the leg that follows, asserted on THIS capture because
311 # this is the run that had a pty: the daemon emits both side channels for
312 # this session, right now. Without it, "neither reached the non-tty client"
313 # would pass just as well on a daemon that had stopped sending either.
314 grep -qaF "$(printf '\033[?2004h')" "$OUT.title" || {
315 echo "e2e FAIL: the session armed bracketed paste but the tty client never heard"
316 cat -v "$OUT.title"; exit 1; }
317 rm_swept "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh"
318 ok "the window title reaches the host as OSC 0, pushed before and popped after"
319
320 # --- side channel: no terminal taken over, no terminal state changed -----
321 #
322 # The bug this pins shipped and was fixed in c18c54b. `is_tty` is isatty of
323 # STDIN — it gates raw mode and the alt-screen entry, both input concerns —
324 # while the side-channel arms write to STDOUT. A client with stdin
325 # redirected therefore never set `alt_screen`, never ran the teardown, and
326 # yet happily wrote the session's title and `?2004h` to whatever stdout
327 # was. Measured on the parent commit, against this same daemon: the capture
328 # ended `...ESC[?2004h ESC]0;mux-e2e-title BEL` — the title set and the
329 # paste mode set as the final bytes, and nothing anywhere to undo either.
330 #
331 # Same session as the leg above, which is what makes it meaningful: that
332 # leg just proved with a pty that the daemon is sending both of these.
333 #
334 # stdin from /dev/null and stdout to a FILE, so mux takes nothing over.
335 timeout 4 "$MUX" --sock "$SOCK" < /dev/null > "$OUT.nogate" 2>&1 || true
336 # Positive control first: this capture must be of a client that ATTACHED
337 # and painted, or every negative below it is vacuous. TITLEDONE is on that
338 # session's screen from the leg above, so it arrives by the ordinary grid
339 # path — the path that is supposed to keep working here.
340 grep -qaF "TITLEDONE" "$OUT.nogate" || {
341 echo "e2e FAIL: the non-tty client never painted the session (nothing to conclude)"
342 cat -v "$OUT.nogate"; exit 1; }
343 # -F on every needle, for the reason documented at the dead-transport leg:
344 # without it these are malformed bracket expressions, grep exits 2, and
345 # `grep && { fail }` reads an error as "not found".
346 #
347 # `ESC]0;` is the bare prefix rather than the full title: ANY title write is
348 # the failure here, not just that one session's.
349 for NEEDLE in "$(printf '\033]0;')" "$(printf '\033[?2004h')" "$(printf '\033[22;0t')"; do
350 grep -qaF "$NEEDLE" "$OUT.nogate" && {
351 echo "e2e FAIL: side channel written to a terminal mux never took over"
352 cat -v "$OUT.nogate"; exit 1; }
353 done
354 rm_swept "$OUT.nogate"
355 ok "a client that takes no terminal over changes no terminal state"
356
357 # --- side channel: a paste into a real editor keeps its indentation ------
358 #
359 # The byte-level pin (?2004h in a host capture) proves the FRAME arrived;
360 # this proves pasting WORKS. Measured on 2026-08-15 before the fix: the
361 # third line of this exact block came out with 8 spaces instead of 4 — the
362 # classic autoindent staircase — because the host terminal was never told
363 # the application had asked for bracketed paste, and the host terminal is
364 # the only thing that can actually bracket one.
365 #
366 # ptyclient's `paste` verb brackets only when it has SEEN ?2004h go past on
367 # the capture, which is what a real terminal does and is why this scenario
368 # cannot pass on a mirror that stopped working: with the mirror neutered the
369 # same bytes arrive unbracketed, nvim autoindents them, and the file below
370 # holds the staircase. The assertion is that file, not an escape — a grep
371 # for the escape would go on passing while paste itself broke.
372 #
373 # nvim is REQUIRED, not guarded with `command -v`. A guard would let a box
374 # without nvim skip this quietly, and a silently-skipped scenario is exactly
375 # what the OK_COUNT pin at the bottom exists to catch — so the dependency
376 # fails loudly. The check itself is HOISTED to the head of this file, next to
377 # the argument binding: a box without nvim should hear about it before it
378 # spends minutes getting here.
379 #
380 # That reverses M9's standing rule (2026-08-08-m9-prediction.md: "rawmode
381 # helper, not nvim, in CI"), which gave two reasons. Its PRESENCE half is
382 # answered by the check above — refusing to run beats running less. Its
383 # NONDETERMINISM half — nvim's redraw timing — is answered by construction:
384 # every wait below is on a CONDITION (a needle that must arrive) or on
385 # SILENCE (settle), and neither is a bet on when a redraw happens. If this
386 # scenario ever flakes, that is the property to check first; a wait added
387 # here that keys off redraw timing would be the regression.
388 #
389 # Unquoted heredoc, like the clipboard leg above: the send verb carries
390 # $OUT's per-run path. `\x1b`, `\r`, `\n` and `\x1c` are the fixture's
391 # escapes and the shell leaves every one of them alone.
392 #
393 # -i NONE keeps nvim off the operator's shada file; -u NONE keeps it off
394 # their config, so 'autoindent' is set here and nowhere else — the staircase
395 # this measures has to come from the option this line spells.
396 #
397 # `timeout 60` is under the heredoc's own deadlines (120s if every one of
398 # them ran long), and that is deliberate rather than copied from the leg
399 # above: those deadlines are per-verb ceilings for a fixture that is still
400 # talking, and a run that genuinely needs more than 60s wall has something
401 # wrong with it that a bare `exited 124` describes about as well as a
402 # fixture dump would. The cost is real — 124 loses the "last N bytes
403 # received" line — so anything raised here should raise this too.
404 set +e
405 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \
406 -- "$MUX" --sock "$SOCK" > "$OUT.paste.log" 2>&1 <<EOF
407 expect \x1b[?1049h 15000
408 settle 400 15000
409 send nvim -u NONE -i NONE -c "set autoindent" -c startinsert $OUT.pasted.txt\n
410 # The mode indicator, whole. The line above echoes "startinsert", so a bare
411 # INSERT needle would differ from the echo by capitalisation alone (the M12
412 # tp2 rule passing on a technicality); the dashes are what that echo cannot
413 # spell.
414 #
415 # And NOT an expect for the 2004h escape, deliberately, however much this
416 # reads like the place for one. The fixture watches for that escape without
417 # waiting on it: it brackets a paste when it has seen one and sends bare
418 # bytes when it has not. A needle for it here would wait, and that changes
419 # what a broken mirror looks like — instead of the file below holding the
420 # staircase, the run would time out on the needle and never reach the
421 # assertion at all. The escape ARRIVING is already pinned, in client.zig's
422 # unit tests. What THIS leg measures is pasting working, and it can only
423 # measure that if the escape's absence is allowed through to the editor.
424 expect -- INSERT -- 20000
425 settle 500 20000
426 paste if x:\r a = 1\r b = 2\r
427 settle 500 20000
428 # Two sends, not one. Delivered in the same read as the colon, nvim's input
429 # parser is entitled to read the ESC as the Alt- prefix of Alt-colon rather
430 # than as leaving insert mode, and the file would never be written. The
431 # settle between them is not a timer for a repaint: what the split needs is
432 # the ESC and the colon landing in different reads, and this verb cannot
433 # return in under its quiet window whatever the screen does, which is that
434 # separation — by far outlasting the 50ms ttimeoutlen the ambiguity turns
435 # on. (settle proves silence, never arrival; see its docstring.)
436 send \x1b
437 settle 300 15000
438 send :wq\r
439 # What makes the file safe to read after this is the ORDERING, not the
440 # number: nvim writes the buffer before it exits, so everything that follows
441 # on the master — its exit repaint, then the shell's prompt — comes after
442 # the write, and silence measured past all of that cannot precede it. The
443 # 800 is slack on top of a guarantee, not a measurement of how long nvim
444 # takes.
445 settle 800 20000
446 send \x1c\x1c
447 waitexit 10000
448 EOF
449 RC=$?
450 set -e
451 [ "$RC" -eq 0 ] || {
452 echo "e2e FAIL: paste scenario did not run: ptyclient exited $RC"
453 cat "$OUT.paste.log"; cat -v "$OUT.paste.err" 2>/dev/null; exit 1; }
454 # Separate from the indentation check on purpose: "nvim never wrote" and
455 # "nvim wrote the wrong thing" are different failures, and one message for
456 # both would send the reader looking at the wrong half.
457 [ -s "$OUT.pasted.txt" ] || {
458 echo "e2e FAIL: nvim wrote no file — the paste never reached the editor"
459 cat "$OUT.paste.log"; exit 1; }
460 grep -q '^ b = 2$' "$OUT.pasted.txt" || {
461 echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)"
462 cat -A "$OUT.pasted.txt"; exit 1; }
463 rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
464 ok "a paste into nvim keeps its indentation"
465
466 # --- side channel: a burst of bells reaches the host as exactly one ------
467 #
468 # This leg exists for the COALESCING, not for the bell. The shared client core
469 # decodes every term_event, then every accepted effect follows the same
470 # writeSideChannel → appendHostEffect path, so the clipboard leg above already
471 # proves frame → decode → adapter → host tty for the whole frame type. A bell
472 # that merely ARRIVED would add no coverage: the bell-specific adapter line is
473 # pinned directly in client.zig. What only e2e can see is the NUMBER. Five rings
474 # inside one pty chunk must reach
475 # the host as one (server.zig, drainSideEvents), and a regression there is
476 # silent — every assertion anyone would think to write about a bell still
477 # passes while 256 frames per chunk go out.
478 #
479 # So the assertion is a count, and a count of BEL BYTES is only unambiguous
480 # if nothing else in the capture can contribute one. Two things make that
481 # true here, and both are asserted rather than assumed:
482 #
483 # 1. A daemon of its own. The client writes exactly two OSCs, ever —
484 # `ESC]52;` and `ESC]0;` (client.zig), each BEL-terminated — and $SOCK's
485 # session has had a title set on it by the leg above, which every client
486 # attaching there is then told about. On a fresh daemon nothing sets a
487 # title and nothing copies, so neither OSC has an occasion to be built.
488 # 2. The zero-OSC check below, which is what turns that argument into a
489 # measurement. With no `ESC]` anywhere in the capture, no BEL in it can
490 # be terminating a sequence.
491 #
492 # That leaves one way a BEL could arrive with no `ESC]` in front of it, which
493 # check 2 would NOT catch: as painted cell content, since paint.zig writes
494 # row.bytes verbatim. It cannot arise here — ghostty-vt executes C0 rather
495 # than storing it, and this session paints a prompt, an echo and BELLDONE —
496 # but the two checks above are what make the count unambiguous, not a proof
497 # that no other path to a BEL exists.
498 start_daemon "$SOCK24" "$OUT.d21.d" "bell daemon socket never appeared" --shell /bin/sh
499 D21PID=$DPID
500
501 # Five rings in ONE printf, which is one write and so in practice one pty read
502 # and one drain — the line discipline is not going to split five bytes short of
503 # a full output buffer, but that is a property of the read side rather than
504 # anything the single write guarantees. Split across two writes they would be
505 # two chunks and two frames, correctly, since coalescing is per drain; the
506 # single printf is what makes "one" the right answer here. A split would show
507 # up as `2 bells ... want exactly 1`, so read that as a chunking accident
508 # before reading it as lost coalescing.
509 #
510 # Emitted by a FILE, never typed, for the M12 reason spelled out on the
511 # clipboard leg: the shell echoes what is typed, so a needle that could
512 # arrive as an echo would pass on a client that forwards nothing at all.
513 cat > "$OUT.bell.sh" <<'BELLSH'
514 printf '\007\007\007\007\007'
515 printf 'BELLDONE\n'
516 BELLSH
517 set +e
518 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.bell" --err "$OUT.bell.err" \
519 -- "$MUX" --sock "$SOCK24" > "$OUT.bell.log" 2>&1 <<EOF
520 expect \x1b[?1049h 15000
521 settle 400 15000
522 send sh $OUT.bell.sh\n
523 expect BELLDONE 15000
524 settle 400 15000
525 send \x1c\x1c
526 waitexit 10000
527 EOF
528 RC=$?
529 set -e
530 [ "$RC" -eq 0 ] || {
531 echo "e2e FAIL: bell scenario did not run: ptyclient exited $RC"
532 cat "$OUT.bell.log"; cat -v "$OUT.bell.err" 2>/dev/null; exit 1; }
533 # The positive control, as on every leg here: the marker travels the ordinary
534 # grid path, so without it "one bell" and "no session" are the same reading —
535 # and "zero bells" would pass the zero-OSC check below just as well.
536 grep -qa 'BELLDONE' "$OUT.bell" || {
537 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
538 # -F, and it is not decoration: the needle is `ESC]`, and `]` unescaped makes
539 # this a malformed bracket expression — grep exits 2 and `grep && { fail }`
540 # reads the error as "not found". That exact defect has already shipped in
541 # this file once (see the dead-transport leg).
542 OSCINTRO=$(printf '\033]')
543 grep -qaF "$OSCINTRO" "$OUT.bell" && {
544 echo "e2e FAIL: an OSC reached the bell capture, so its BELs cannot be counted"
545 cat -v "$OUT.bell"; exit 1; }
546 # ...and now the count means what it says. `occurrences` is the title leg's
547 # helper, defined above and reused here on the same kind of byte needle.
548 BELBYTE=$(printf '\007')
549 BELLN=$(occurrences "$OUT.bell" "$BELBYTE")
550 [ "$BELLN" -eq 1 ] || {
551 echo "e2e FAIL: $BELLN bells on the host tty for one burst of five, want exactly 1"
552 cat -v "$OUT.bell"; exit 1; }
553 "$MUXD" stop --sock "$SOCK24" > /dev/null 2>&1 || true
554 wait_pid_gone "$D21PID" "bell leg: stop reported stopped"
555 D21PID=""
556 rm_swept "$OUT.bell" "$OUT.bell.err" "$OUT.bell.log" "$OUT.bell.sh" "$OUT.d21.d"
557 ok "a burst of bells in one chunk reaches the host as exactly one"
558
559 # The long-lived daemon has served every scenario that wanted it; stop it
560 # NOW so its allocator verdict is written while the suite is still running
561 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
562 # (and the verdict) runs.
563 #
564 # The sweep that READS that verdict is in the EXIT trap, not here: this line
565 # is only reached by a run that passed, and a leak deserves reporting on the
566 # runs that did not (see leak_sweep). Everything below it is a pin, and the
567 # trap fires after all of them either way.
568 softkill "$D1PID" || true
569 wait "$D1PID" 2>/dev/null || true
570 D1PID=""