d367443a
test: 03_side moves to a file of its own
a73x 2026-08-26 18:49
Commit message
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="" | ||