95afc45d
test(xversion): tighten side-channel compatibility controls
a73x 2026-08-16 07:42
Commit message
test/xversion.sh
| Old | New | ||
|---|---|---|---|
| @@ -56,11 +56,22 @@ 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 | 59 | # The pty fixture legs 7 and 8 need. Both clients in those legs run under |
| 60 | # directions on purpose: it is a HARNESS, not a subject, and a | 60 | # whatever `$NEW_BIN` names, because the fixture is a HARNESS rather than a |
| 61 | # falsification run (XVER_OLD_BIN pointed at the new binaries) must still | 61 | # subject: which version of mux is under test is decided by the argument |
| 62 | # have one. Those legs cannot run without it because mux writes a side | 62 | # after `--`, not by whose pty it runs on. |
| 63 | # channel only to a terminal it has taken over — see the leg's own header. | 63 | # |
| 64 | # That is NOT the same as "always this tree's fixture", and an earlier | ||
| 65 | # draft of this comment said so and was wrong. Under a falsification run | ||
| 66 | # with XVER_NEW_BIN pointed at the old prefix, this IS the old tree's | ||
| 67 | # fixture — it happens to work, because v0.0.1-5's ptyclient speaks the | ||
| 68 | # same script dialect, and that direction was observed failing for the | ||
| 69 | # right reason. But it is a coincidence the code does not enforce, so a | ||
| 70 | # future dialect change would break that direction rather than this line | ||
| 71 | # holding it up. | ||
| 72 | # | ||
| 73 | # Legs 7 and 8 cannot run without a pty at all, because mux writes a side | ||
| 74 | # channel only to a terminal it has taken over — see the legs' own header. | ||
| 64 | [ -x "$NEW_BIN/ptyclient" ] || { | 75 | [ -x "$NEW_BIN/ptyclient" ] || { |
| 65 | echo "xver FAIL: no ptyclient in $NEW_BIN — legs 7 and 8 need the pty fixture" | 76 | 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" | 77 | echo " it is built by this tree's build.zig into the same prefix as mux/muxd" |
| @@ -336,43 +347,73 @@ fi | |||
| 336 | # tree's own client does not SHOW the side channels over this door, then | 347 | # 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 | 348 | # 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. | 349 | # that is a FAIL here — not a skip and not a green. |
| 350 | # The clipboard needle, and its terminator is the instrument: the session | ||
| 351 | # writes OSC 52 terminated by ST and this expects BEL. See the note on | ||
| 352 | # durability above xver_side_channel. | ||
| 339 | SC_OSC52=$(printf '\033]52;c;aGk=\007') | 353 | SC_OSC52=$(printf '\033]52;c;aGk=\007') |
| 340 | SC_BP=$(printf '\033[?2004h') | 354 | SC_BP_ON=$(printf '\033[?2004h') |
| 355 | SC_BP_OFF=$(printf '\033[?2004l') | ||
| 341 | 356 | ||
| 342 | # side_channel_session TAG CLIENTBIN TRANSPORTARGS... — attach one client of | 357 | # side_channel_session TAG CLIENTBIN TRANSPORTARGS... — attach one client of |
| 343 | # one version over one transport, make the session emit all three side | 358 | # 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. | 359 | # channels and then a marker, and leave the capture at $TMP/sc-TAG. |
| 345 | # | 360 | # |
| 361 | # Bound to the NEW daemon and only to it: the script is written to | ||
| 362 | # $RUN_NEW but sent as the container path /run/mux/..., which is right for | ||
| 363 | # either daemon (both mount their rundir there) and would therefore fail a | ||
| 364 | # leg pointed at $SOCK_OLD with a message about the session rather than | ||
| 365 | # about the file being on the wrong side. | ||
| 366 | # | ||
| 346 | # Driven by a FILE the session runs, never by typed input: the shell echoes | 367 | # 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 | 368 | # 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 | 369 | # 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 | 370 | # `sh /run/mux/sc-TAG.sh` and the marker is `scTAG-pin`, which that line |
| 350 | # does not contain. | 371 | # does not contain. |
| 351 | # | 372 | # |
| 352 | # Each side channel needs its own arrangement to fire LIVE while this | 373 | # FOUR CHUNKS, A SECOND APART, AND THE SPACING IS THE WHOLE DESIGN. Each |
| 353 | # client is attached, and two of the three are sampled rather than | 374 | # `printf` is its own write(2) into one daemon read loop, so whether two of |
| 354 | # streamed: | 375 | # them arrive as one read is a matter of scheduling and not of anything |
| 355 | # * `?2004l` before `?2004h`, because sampleTermModes early-returns when | 376 | # this script can state. That matters because `sampleTermModes` compares |
| 356 | # the value has not changed and an earlier call of this helper left the | 377 | # against the last value SENT and early-returns when it is unchanged: a |
| 357 | # session already bracketing. | 378 | # coalesced `h,l,h` against a session already bracketing nets to `true`, |
| 358 | # * a title unique to TAG, because sampleTermTitle early-returns the same | 379 | # equals what the attach already reported, and emits NOTHING. Probing that |
| 359 | # way on an unchanged title. | 380 | # and seeing two frames would establish only that it did not happen that |
| 360 | # * OSC 52 needs nothing: a term_event is an occurrence rather than | 381 | # time. Spaced out, it cannot happen at all: |
| 361 | # sampled state, so it fires every time. | 382 | # |
| 362 | # The attach-time resync sends term_modes and term_title too, which is why | 383 | # chunk A `?2004h` + the clipboard + the title. Leaves bracketing ON |
| 363 | # the arrangement matters — without it the only frames of those two types | 384 | # whatever earlier calls of this helper left behind; whether |
| 364 | # would be ones that arrived BEFORE the marker for an uninteresting reason. | 385 | # that is itself a frame is unknown and does not matter. |
| 386 | # chunk B `?2004l`. ON -> OFF against a known state, so ALWAYS a live | ||
| 387 | # term_modes(false). | ||
| 388 | # chunk C `?2004h`. OFF -> ON, so ALWAYS a live term_modes(true). | ||
| 389 | # chunk D the marker, which must be last for the whole leg to mean | ||
| 390 | # anything — see the legs' header. | ||
| 391 | # | ||
| 392 | # The title takes its own arrangement rather than spacing: it is unique per | ||
| 393 | # TAG because `sampleTermTitle` early-returns on an unchanged title exactly | ||
| 394 | # as the modes sampler does, and the attach-time resync has already sent | ||
| 395 | # the PREVIOUS tag's. The clipboard needs neither — a term_event is an | ||
| 396 | # occurrence rather than sampled state, so it fires every time. | ||
| 365 | side_channel_session() { | 397 | side_channel_session() { |
| 366 | _sc_tag="$1"; _sc_cli="$2"; shift 2 | 398 | _sc_tag="$1"; _sc_cli="$2"; shift 2 |
| 367 | cat > "$RUN_NEW/sc-$_sc_tag.sh" <<EOF | 399 | cat > "$RUN_NEW/sc-$_sc_tag.sh" <<EOF |
| 368 | printf '\033[?2004l' | 400 | printf '\033[?2004h' |
| 369 | printf '\033]52;c;aGk=\007' | 401 | printf '\033]52;c;aGk=\033\\' |
| 370 | printf '\033]2;xvsc-$_sc_tag\007' | 402 | printf '\033]2;xvsc-$_sc_tag\007' |
| 403 | sleep 1 | ||
| 404 | printf '\033[?2004l' | ||
| 405 | sleep 1 | ||
| 371 | printf '\033[?2004h' | 406 | printf '\033[?2004h' |
| 372 | sleep 1 | 407 | sleep 1 |
| 373 | printf 'sc%s-%s\n' $_sc_tag pin | 408 | printf 'sc%s-%s\n' $_sc_tag pin |
| 374 | EOF | 409 | EOF |
| 375 | timeout 60 "$NEW_BIN/ptyclient" --cols 80 --rows 24 \ | 410 | # 90 and not 60: the script's verbs below sum to 70s of deadline in the |
| 411 | # worst case, so a 60s outer kill would fire FIRST and report a hung | ||
| 412 | # `expect` as "the control never ran" or "the old client did not | ||
| 413 | # survive" — a fixture failure attributed to the subject. The outer | ||
| 414 | # timeout is a backstop and has to sit outside the primary deadlines to | ||
| 415 | # stay one. | ||
| 416 | timeout 90 "$NEW_BIN/ptyclient" --cols 80 --rows 24 \ | ||
| 376 | --out "$TMP/sc-$_sc_tag" --err "$TMP/sc-$_sc_tag.err" \ | 417 | --out "$TMP/sc-$_sc_tag" --err "$TMP/sc-$_sc_tag.err" \ |
| 377 | -- "$_sc_cli" "$@" > "$TMP/sc-$_sc_tag.log" 2>&1 <<SCRIPT | 418 | -- "$_sc_cli" "$@" > "$TMP/sc-$_sc_tag.log" 2>&1 <<SCRIPT |
| 378 | expect \x1b[?1049h 15000 | 419 | expect \x1b[?1049h 15000 |
| @@ -387,11 +428,30 @@ SCRIPT | |||
| 387 | 428 | ||
| 388 | # xver_side_channel LABEL TRANSPORTARGS... — one leg, one door. | 429 | # xver_side_channel LABEL TRANSPORTARGS... — one leg, one door. |
| 389 | # | 430 | # |
| 390 | # The title needle is OSC **0** against a session that wrote OSC **2**, and | 431 | # Each needle is chosen so that only a client which DECODED the frame can |
| 391 | # the asymmetry is the instrument (e2e.sh argues it at length on its own | 432 | # satisfy it. A client that forwarded raw session bytes would put back what |
| 392 | # title leg): a client that forwarded session bytes would put back what | 433 | # went in, so every needle differs from what the session wrote: |
| 393 | # went in, so `ESC]0;` for a session that only ever wrote `ESC]2;` can only | 434 | # |
| 394 | # be the client re-rendering a term_title frame it decoded. | 435 | # term_title session writes OSC **2**, needle is OSC **0** |
| 436 | # term_event session terminates OSC 52 with **ST**, needle wants **BEL** | ||
| 437 | # term_modes no single byte distinguishes it; the ORDER does — below | ||
| 438 | # | ||
| 439 | # The two are not equally durable, and it is the reverse of what the weight | ||
| 440 | # of argument in e2e.sh suggests. The title's asymmetry is a transform mux | ||
| 441 | # HAPPENS to perform: `Engine.title()` is one field, so OSC 0 and OSC 2 | ||
| 442 | # collapse into it and the client re-emits the form it prefers. A change | ||
| 443 | # that made the client round-trip the original sequence would be perfectly | ||
| 444 | # reasonable and would retire that instrument silently. The clipboard's is | ||
| 445 | # a transform mux CANNOT avoid: `TermEvent.clipboard` carries a target and | ||
| 446 | # base64 and has no field for a terminator, so the session's ST cannot | ||
| 447 | # survive the wire, and `appendTermEvent` appends 0x07 unconditionally | ||
| 448 | # (client.zig). Only a protocol change could break that one. So if one of | ||
| 449 | # these needles has to be trusted, trust the clipboard's. | ||
| 450 | # | ||
| 451 | # (ghostty dispatches OSC on ST as well as BEL — its parser leaves the | ||
| 452 | # osc_string state on ESC, which fires the same osc_dispatch BEL produces. | ||
| 453 | # Verified at source rather than assumed, because the whole leg rests on | ||
| 454 | # the session's ST reaching the engine as an ordinary clipboard event.) | ||
| 395 | xver_side_channel() { | 455 | xver_side_channel() { |
| 396 | _lbl="$1"; shift | 456 | _lbl="$1"; shift |
| 397 | _sc_title_n=$(printf '\033]0;xvsc-n%s\007' "$_lbl") | 457 | _sc_title_n=$(printf '\033]0;xvsc-n%s\007' "$_lbl") |
| @@ -412,10 +472,36 @@ xver_side_channel() { | |||
| 412 | elif ! grep -qaF "$_sc_title_n" "$_sc_cap"; then | 472 | 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" | 473 | fail "$_lbl: no title reached this tree's own client over this door — the old client below would be ignoring nothing" |
| 414 | return | 474 | 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 | 475 | fi |
| 476 | # The modes channel, asserted by ORDER and not by presence, because | ||
| 477 | # presence proves almost nothing here: `sendSampledStateTo` queues a | ||
| 478 | # term_modes on EVERY attach (server.zig), and by this point the | ||
| 479 | # session has usually been left bracketing by an earlier call, so a | ||
| 480 | # bare `?2004h` needle is satisfied before the session script runs at | ||
| 481 | # all — a regression that broke `sampleTermModes` outright would leave | ||
| 482 | # it green. That was a real finding against the first version of this | ||
| 483 | # leg, not a hypothetical. | ||
| 484 | # | ||
| 485 | # What an attach cannot fake is a SECOND escape: it sends exactly one | ||
| 486 | # term_modes, so every mode escape after the first is necessarily a | ||
| 487 | # live sample. Chunks B and C make two of them unconditional, so the | ||
| 488 | # sequence contains `h` then `l` then `h` whichever value the attach | ||
| 489 | # reported — `l,h,l,h` when the session was not bracketing, `h,l,h` | ||
| 490 | # when it was. Both are expected and both must match; the counts differ | ||
| 491 | # between the two doors for exactly this reason, which is why this is a | ||
| 492 | # pattern rather than a count. | ||
| 493 | # | ||
| 494 | # Extracted with -F needles and a `sed` that keeps only the final | ||
| 495 | # letter, so nothing here builds a regex out of escape bytes. | ||
| 496 | _sc_modes=$(grep -oaF -e "$SC_BP_ON" -e "$SC_BP_OFF" "$_sc_cap" | | ||
| 497 | sed 's/.*4//' | tr -d '\n') | ||
| 498 | case "$_sc_modes" in | ||
| 499 | *h*l*h*) ;; | ||
| 500 | *) | ||
| 501 | fail "$_lbl: no live term_modes reached this tree's own client over this door — saw '$_sc_modes', want h…l…h (one of those is the attach's; the rest are the sampler's)" | ||
| 502 | return | ||
| 503 | ;; | ||
| 504 | esac | ||
| 419 | 505 | ||
| 420 | side_channel_session "o$_lbl" "$OLD_BIN/mux" "$@" | 506 | side_channel_session "o$_lbl" "$OLD_BIN/mux" "$@" |
| 421 | _sc_rc=$? | 507 | _sc_rc=$? |
| @@ -442,7 +528,7 @@ xver_side_channel() { | |||
| 442 | _sc_wrote="" | 528 | _sc_wrote="" |
| 443 | grep -qaF "$SC_OSC52" "$_sc_cap" && _sc_wrote="$_sc_wrote clipboard(term_event)" | 529 | 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)" | 530 | 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)" | 531 | grep -qaF "$SC_BP_ON" "$_sc_cap" && _sc_wrote="$_sc_wrote bracketed-paste(term_modes)" |
| 446 | if [ -n "$_sc_wrote" ]; then | 532 | 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" | 533 | 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 | 534 | else |