a73x

90f49498

test(xversion): an old client meets the new side-channel frames, both doors

a73x   2026-08-15 21:21

Commit message
test(xversion): an old client meets the new side-channel frames, both doors

The spec ASSERTS that a client with no arm for term_modes (0x8d),
term_title (0x8e) or term_event (0x8f) lands in `else => {}` and ignores
them. This rig exists to distrust assertions of that shape, and on both
transports for leg 3's reason: one sentence about a pre-M18 daemon was
right about the socket and wrong about QUIC.

The claim pinned is not "the old client did not crash" but that it kept
PARSING: a mis-framed type turns every later byte to garbage, so the
session emits all three side channels, waits a second so they land in a
pump of their own, and only then prints a marker the old client must
still paint. From a file the session runs, never typed input.

Both clients run on a real tty, which is what makes the negative half
mean anything -- over a pipe neither version writes a side channel, so
"the old client wrote no title" would be a fact about the pipe. And this
tree's own client runs first over the same door as a control: if it does
not show the side channels then nothing was sent, and a clean run by the
old client is asserting nothing. That is a FAIL, not a green.

Falsified both ways. XVER_OLD_BIN at the new binaries: both legs fail
naming all three channels the "old" client rendered. XVER_NEW_BIN at the
old binaries: both legs fail on the control, no clipboard sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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