90f49498
test(xversion): an old client meets the new side-channel frames, both doors
a73x 2026-08-15 21:21
Commit message
test/xversion.sh
| Old | New | ||
|---|---|---|---|
| @@ -56,6 +56,16 @@ for _d in "$OLD_BIN" "$NEW_BIN"; do | |||
| 56 | } | 56 | } |
| 57 | done | 57 | done |
| 58 | done | 58 | done |
| 59 | # The pty fixture legs 7 and 8 need. Taken from the NEW tree in both | ||
| 60 | # directions on purpose: it is a HARNESS, not a subject, and a | ||
| 61 | # falsification run (XVER_OLD_BIN pointed at the new binaries) must still | ||
| 62 | # have one. Those legs cannot run without it because mux writes a side | ||
| 63 | # channel only to a terminal it has taken over — see the leg's own header. | ||
| 64 | [ -x "$NEW_BIN/ptyclient" ] || { | ||
| 65 | echo "xver FAIL: no ptyclient in $NEW_BIN — legs 7 and 8 need the pty fixture" | ||
| 66 | echo " it is built by this tree's build.zig into the same prefix as mux/muxd" | ||
| 67 | exit 1 | ||
| 68 | } | ||
| 59 | 69 | ||
| 60 | # Short paths on purpose: these directories hold unix sockets, and a socket | 70 | # Short paths on purpose: these directories hold unix sockets, and a socket |
| 61 | # path is capped near 108 bytes by the kernel, not by anything this script | 71 | # path is capped near 108 bytes by the kernel, not by anything this script |
| @@ -290,6 +300,160 @@ else | |||
| 290 | pass "stats_req discriminates the versions (a probe the old daemon already answers)" | 300 | pass "stats_req discriminates the versions (a probe the old daemon already answers)" |
| 291 | fi | 301 | fi |
| 292 | 302 | ||
| 303 | # ---- legs 7 and 8: an old client meets the new side-channel frames ----- | ||
| 304 | # This branch added three daemon->client frame types — term_modes (0x8d), | ||
| 305 | # term_title (0x8e), term_event (0x8f) — and the new daemon queues them to | ||
| 306 | # EVERY client in the session. An old client has no arm for any of them and | ||
| 307 | # the spec ASSERTS it lands in `else => {}` and ignores them. That is the | ||
| 308 | # shape of claim this rig exists to distrust, and both doors are asked for | ||
| 309 | # leg 3's reason: one sentence about a pre-M18 daemon was right about the | ||
| 310 | # socket and wrong about QUIC, and that cost a debugging session. | ||
| 311 | # | ||
| 312 | # The claim under test is not "the old client did not crash". It is that it | ||
| 313 | # KEPT PARSING. A type it mis-framed would make every byte after that frame | ||
| 314 | # garbage, so the session emits the side channels, waits, and only THEN | ||
| 315 | # prints a marker: a marker painted on the old client's screen is proof the | ||
| 316 | # frame boundary survived three frames it had never heard of. | ||
| 317 | # | ||
| 318 | # The wait is load-bearing, and the reason is the daemon's own order within | ||
| 319 | # one pty chunk: `sendUpdate` (the delta) runs FIRST, then drainSideEvents, | ||
| 320 | # sampleTermModes and sampleTermTitle (server.zig, the pty-readable arm). | ||
| 321 | # Emitted in a single chunk the marker's delta would reach the client | ||
| 322 | # BEFORE the three frames and this leg would prove nothing about parsing | ||
| 323 | # past them. A second apart they are separate reads, separate pumps, and | ||
| 324 | # the frames strictly precede the marker. | ||
| 325 | # | ||
| 326 | # BOTH clients run on a real tty (test/ptyclient), and that is what makes | ||
| 327 | # the negative half assertable: the client writes a side channel only to a | ||
| 328 | # terminal it has taken over, so over a pipe a client of EITHER version | ||
| 329 | # writes none of them — "the old client wrote no title" would then be a | ||
| 330 | # fact about the pipe rather than about the version, and the leg would | ||
| 331 | # survive its own falsification saying nothing. | ||
| 332 | # | ||
| 333 | # Control and subject share one leg rather than reporting separately, | ||
| 334 | # because a control that passes while its subject quietly stops being | ||
| 335 | # tested is exactly the failure mode this file is built against. If this | ||
| 336 | # tree's own client does not SHOW the side channels over this door, then | ||
| 337 | # nothing was sent, the old client's clean run is asserting nothing, and | ||
| 338 | # that is a FAIL here — not a skip and not a green. | ||
| 339 | SC_OSC52=$(printf '\033]52;c;aGk=\007') | ||
| 340 | SC_BP=$(printf '\033[?2004h') | ||
| 341 | |||
| 342 | # side_channel_session TAG CLIENTBIN TRANSPORTARGS... — attach one client of | ||
| 343 | # one version over one transport, make the session emit all three side | ||
| 344 | # channels and then a marker, and leave the capture at $TMP/sc-TAG. | ||
| 345 | # | ||
| 346 | # Driven by a FILE the session runs, never by typed input: the shell echoes | ||
| 347 | # what is typed, so a marker that could arrive as an echo would pass on a | ||
| 348 | # client that forwarded nothing (M12's rule). The typed line is the path | ||
| 349 | # `sh /run/mux/sc-TAG.sh` and the marker is `scTAG-pin`, which that line | ||
| 350 | # does not contain. | ||
| 351 | # | ||
| 352 | # Each side channel needs its own arrangement to fire LIVE while this | ||
| 353 | # client is attached, and two of the three are sampled rather than | ||
| 354 | # streamed: | ||
| 355 | # * `?2004l` before `?2004h`, because sampleTermModes early-returns when | ||
| 356 | # the value has not changed and an earlier call of this helper left the | ||
| 357 | # session already bracketing. | ||
| 358 | # * a title unique to TAG, because sampleTermTitle early-returns the same | ||
| 359 | # way on an unchanged title. | ||
| 360 | # * OSC 52 needs nothing: a term_event is an occurrence rather than | ||
| 361 | # sampled state, so it fires every time. | ||
| 362 | # The attach-time resync sends term_modes and term_title too, which is why | ||
| 363 | # the arrangement matters — without it the only frames of those two types | ||
| 364 | # would be ones that arrived BEFORE the marker for an uninteresting reason. | ||
| 365 | side_channel_session() { | ||
| 366 | _sc_tag="$1"; _sc_cli="$2"; shift 2 | ||
| 367 | cat > "$RUN_NEW/sc-$_sc_tag.sh" <<EOF | ||
| 368 | printf '\033[?2004l' | ||
| 369 | printf '\033]52;c;aGk=\007' | ||
| 370 | printf '\033]2;xvsc-$_sc_tag\007' | ||
| 371 | printf '\033[?2004h' | ||
| 372 | sleep 1 | ||
| 373 | printf 'sc%s-%s\n' $_sc_tag pin | ||
| 374 | EOF | ||
| 375 | timeout 60 "$NEW_BIN/ptyclient" --cols 80 --rows 24 \ | ||
| 376 | --out "$TMP/sc-$_sc_tag" --err "$TMP/sc-$_sc_tag.err" \ | ||
| 377 | -- "$_sc_cli" "$@" > "$TMP/sc-$_sc_tag.log" 2>&1 <<SCRIPT | ||
| 378 | expect \x1b[?1049h 15000 | ||
| 379 | settle 400 15000 | ||
| 380 | send sh /run/mux/sc-$_sc_tag.sh\n | ||
| 381 | expect sc$_sc_tag-pin 15000 | ||
| 382 | settle 600 15000 | ||
| 383 | send \x1c | ||
| 384 | waitexit 10000 | ||
| 385 | SCRIPT | ||
| 386 | } | ||
| 387 | |||
| 388 | # xver_side_channel LABEL TRANSPORTARGS... — one leg, one door. | ||
| 389 | # | ||
| 390 | # The title needle is OSC **0** against a session that wrote OSC **2**, and | ||
| 391 | # the asymmetry is the instrument (e2e.sh argues it at length on its own | ||
| 392 | # title leg): a client that forwarded session bytes would put back what | ||
| 393 | # went in, so `ESC]0;` for a session that only ever wrote `ESC]2;` can only | ||
| 394 | # be the client re-rendering a term_title frame it decoded. | ||
| 395 | xver_side_channel() { | ||
| 396 | _lbl="$1"; shift | ||
| 397 | _sc_title_n=$(printf '\033]0;xvsc-n%s\007' "$_lbl") | ||
| 398 | _sc_title_o=$(printf '\033]0;xvsc-o%s\007' "$_lbl") | ||
| 399 | |||
| 400 | side_channel_session "n$_lbl" "$NEW_BIN/mux" "$@" | ||
| 401 | _sc_rc=$? | ||
| 402 | _sc_cap="$TMP/sc-n$_lbl" | ||
| 403 | if [ "$_sc_rc" -ne 0 ]; then | ||
| 404 | fail "$_lbl: the control never ran — ptyclient exited $_sc_rc [$(tr -d '\n' < "$TMP/sc-n$_lbl.log")]" | ||
| 405 | return | ||
| 406 | elif ! grep -qaF "scn$_lbl-pin" "$_sc_cap"; then | ||
| 407 | fail "$_lbl: the control's session never ran — no marker on the new client's screen" | ||
| 408 | return | ||
| 409 | elif ! grep -qaF "$SC_OSC52" "$_sc_cap"; then | ||
| 410 | fail "$_lbl: no clipboard reached this tree's own client over this door — the old client below would be ignoring nothing" | ||
| 411 | return | ||
| 412 | elif ! grep -qaF "$_sc_title_n" "$_sc_cap"; then | ||
| 413 | fail "$_lbl: no title reached this tree's own client over this door — the old client below would be ignoring nothing" | ||
| 414 | return | ||
| 415 | elif ! grep -qaF "$SC_BP" "$_sc_cap"; then | ||
| 416 | fail "$_lbl: no bracketed-paste mode reached this tree's own client over this door — the old client below would be ignoring nothing" | ||
| 417 | return | ||
| 418 | fi | ||
| 419 | |||
| 420 | side_channel_session "o$_lbl" "$OLD_BIN/mux" "$@" | ||
| 421 | _sc_rc=$? | ||
| 422 | _sc_cap="$TMP/sc-o$_lbl" | ||
| 423 | if [ "$_sc_rc" -ne 0 ]; then | ||
| 424 | fail "$_lbl: the old client did not survive the side channels — ptyclient exited $_sc_rc [$(tr -d '\n' < "$TMP/sc-o$_lbl.log")]" | ||
| 425 | elif ! grep -qaF "sco$_lbl-pin" "$_sc_cap"; then | ||
| 426 | fail "$_lbl: the old client stopped painting after the side-channel frames — the marker never arrived" | ||
| 427 | else | ||
| 428 | # All three collected rather than chained on elif, and the reason is | ||
| 429 | # this file's own history: an assertion that is never evaluated | ||
| 430 | # passes green forever. Chained, a falsification run trips the first | ||
| 431 | # one and leaves the other two unexecuted — so two of the three | ||
| 432 | # needles would have been trusted without ever having been watched | ||
| 433 | # to fail. Collected, every run executes every grep, and the report | ||
| 434 | # names ALL the channels the client rendered instead of the first. | ||
| 435 | # | ||
| 436 | # `&&` on a grep is safe HERE and would not be in general: an errored | ||
| 437 | # grep exits 2 and reads as "not found", which is the wrong answer. | ||
| 438 | # What rules it out is that the marker grep above has already read | ||
| 439 | # this same file successfully, so it exists and is readable, and the | ||
| 440 | # needles reach grep as -F bytes in a variable, so no metacharacter | ||
| 441 | # can turn one into a bad pattern. | ||
| 442 | _sc_wrote="" | ||
| 443 | grep -qaF "$SC_OSC52" "$_sc_cap" && _sc_wrote="$_sc_wrote clipboard(term_event)" | ||
| 444 | grep -qaF "$_sc_title_o" "$_sc_cap" && _sc_wrote="$_sc_wrote title(term_title)" | ||
| 445 | grep -qaF "$SC_BP" "$_sc_cap" && _sc_wrote="$_sc_wrote bracketed-paste(term_modes)" | ||
| 446 | if [ -n "$_sc_wrote" ]; then | ||
| 447 | fail "$_lbl: the client under test rendered$_sc_wrote — it decoded frames an old client has no arm for, so it is not an old client" | ||
| 448 | else | ||
| 449 | pass "$_lbl: an old client ignores the new side-channel frames and keeps parsing past them" | ||
| 450 | fi | ||
| 451 | fi | ||
| 452 | } | ||
| 453 | |||
| 454 | xver_side_channel socket --sock "$SOCK_NEW" | ||
| 455 | xver_side_channel quic "quic://127.0.0.1:$PORT_NEW" --key "$KEY" --quic-idle-ms 15000 | ||
| 456 | |||
| 293 | # ---- teardown, by the sanctioned verb, then OBSERVED ------------------ | 457 | # ---- teardown, by the sanctioned verb, then OBSERVED ------------------ |
| 294 | "$NEW_BIN/muxd" stop --sock "$SOCK_NEW" >/dev/null 2>&1 | 458 | "$NEW_BIN/muxd" stop --sock "$SOCK_NEW" >/dev/null 2>&1 |
| 295 | "$OLD_BIN/muxd" stop --sock "$SOCK_OLD" >/dev/null 2>&1 | 459 | "$OLD_BIN/muxd" stop --sock "$SOCK_OLD" >/dev/null 2>&1 |
| @@ -313,8 +477,8 @@ fi | |||
| 313 | # silently stops running is the failure mode no assertion inside it can | 477 | # silently stops running is the failure mode no assertion inside it can |
| 314 | # catch. Skips count — a skipped leg RAN and decided not to assert. | 478 | # catch. Skips count — a skipped leg RAN and decided not to assert. |
| 315 | TOTAL=$((PASSES + FAILS + SKIPS)) | 479 | TOTAL=$((PASSES + FAILS + SKIPS)) |
| 316 | if [ "$TOTAL" -ne 8 ]; then | 480 | if [ "$TOTAL" -ne 10 ]; then |
| 317 | echo "xver FAIL: $TOTAL legs reported, want 8 — one did not run" | 481 | echo "xver FAIL: $TOTAL legs reported, want 10 — one did not run" |
| 318 | FAILS=$((FAILS + 1)) | 482 | FAILS=$((FAILS + 1)) |
| 319 | fi | 483 | fi |
| 320 | 484 | ||