a73x

95afc45d

test(xversion): tighten side-channel compatibility controls

a73x   2026-08-16 07:42

Commit message
test(xversion): tighten side-channel compatibility controls

Assert live mode transitions by order, keep the pty deadline outside its
scripted waits, and make the old-client mode check name the explicit
bracketed-paste sequence under set -u.

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