a73x

057cbff8

test: ptyclient pastes like a terminal, and nvim proves it

a73x   2026-08-15 17:20

Commit message
test: ptyclient pastes like a terminal, and nvim proves it

The fixture brackets a paste only when it has SEEN ?2004h, so it behaves
the way a real terminal behaves instead of asserting what the test wishes
were true. The assertion is the file nvim wrote: 4 spaces, not 8 — the
exact measurement taken before this branch existed, inverted into a gate.
Verified falsifiable by neutering the mirror.

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

test/e2e.sh
Old New
@@ -774,6 +774,9 @@ cleanup() {
774 # the session runs. Spelled from $OUT rather than from a variable of its 774 # the session runs. Spelled from $OUT rather than from a variable of its
775 # own, for the reason the M12 controls above are. 775 # own, for the reason the M12 controls above are.
776 rm -f "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" 776 rm -f "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh"
777 # ...and its other half: the paste capture and the file nvim wrote, which
778 # IS that scenario's assertion rather than a log beside it.
779 rm -f "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
777 # M14 handoff. The shim, both runtime dirs (each holding its daemon's 780 # M14 handoff. The shim, both runtime dirs (each holding its daemon's
778 # socket), the second key, the unusable config home, and the five 781 # socket), the second key, the unusable config home, and the five
779 # captures. 782 # captures.
@@ -2732,6 +2735,76 @@ grep -qaF "$CLIPESC" "$OUT.clip" || {
2732 rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh" 2735 rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh"
2733 ok "the session's OSC 52 reaches the host terminal" 2736 ok "the session's OSC 52 reaches the host terminal"
2734 2737
2738 # --- side channel: a paste into a real editor keeps its indentation ------
2739 #
2740 # The byte-level pin (?2004h in a host capture) proves the FRAME arrived;
2741 # this proves pasting WORKS. Measured on 2026-08-15 before the fix: the
2742 # third line of this exact block came out with 8 spaces instead of 4 — the
2743 # classic autoindent staircase — because the host terminal was never told
2744 # the application had asked for bracketed paste, and the host terminal is
2745 # the only thing that can actually bracket one.
2746 #
2747 # ptyclient's `paste` verb brackets only when it has SEEN ?2004h go past on
2748 # the capture, which is what a real terminal does and is why this scenario
2749 # cannot pass on a mirror that stopped working: with the mirror neutered the
2750 # same bytes arrive unbracketed, nvim autoindents them, and the file below
2751 # holds the staircase. The assertion is that file, not an escape — a grep
2752 # for the escape would go on passing while paste itself broke.
2753 #
2754 # nvim is REQUIRED, not guarded with `command -v`. A guard would let a box
2755 # without nvim skip this quietly, and a silently-skipped scenario is exactly
2756 # what the OK_COUNT pin at the bottom exists to catch — so the dependency
2757 # fails loudly here instead of disappearing.
2758 command -v nvim > /dev/null 2>&1 || {
2759 echo "e2e FAIL: this suite needs nvim (the paste scenario asserts on a file"
2760 echo " a real editor wrote); install it, or lose the only check that"
2761 echo " proves bracketed paste works rather than merely arrives"
2762 exit 1; }
2763 # Unquoted heredoc, like the clipboard leg above: the send verb carries
2764 # $OUT's per-run path. `\x1b`, `\r`, `\n` and `\x1c` are the fixture's
2765 # escapes and the shell leaves every one of them alone.
2766 #
2767 # -i NONE keeps nvim off the operator's shada file; -u NONE keeps it off
2768 # their config, so 'autoindent' is set here and nowhere else — the staircase
2769 # this measures has to come from the option this line spells.
2770 #
2771 # ESC and `:wq` are two sends with a settle between them: delivered in one
2772 # read, nvim's input parser is entitled to read `\x1b:` as Alt-: rather than
2773 # as leaving insert mode, and the file would never be written.
2774 set +e
2775 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \
2776 -- "$MUX" --sock "$SOCK" > "$OUT.paste.log" 2>&1 <<EOF
2777 expect \x1b[?1049h 15000
2778 settle 400 15000
2779 send nvim -u NONE -i NONE -c "set autoindent" -c startinsert $OUT.pasted.txt\n
2780 expect INSERT 20000
2781 settle 500 20000
2782 paste if x:\r a = 1\r b = 2\r
2783 settle 500 20000
2784 send \x1b
2785 settle 300 15000
2786 send :wq\r
2787 settle 800 20000
2788 send \x1c
2789 waitexit 10000
2790 EOF
2791 RC=$?
2792 set -e
2793 [ "$RC" -eq 0 ] || {
2794 echo "e2e FAIL: paste scenario did not run: ptyclient exited $RC"
2795 cat "$OUT.paste.log"; cat -v "$OUT.paste.err" 2>/dev/null; exit 1; }
2796 # Separate from the indentation check on purpose: "nvim never wrote" and
2797 # "nvim wrote the wrong thing" are different failures, and one message for
2798 # both would send the reader looking at the wrong half.
2799 [ -s "$OUT.pasted.txt" ] || {
2800 echo "e2e FAIL: nvim wrote no file — the paste never reached the editor"
2801 cat "$OUT.paste.log"; exit 1; }
2802 grep -q '^ b = 2$' "$OUT.pasted.txt" || {
2803 echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)"
2804 cat -A "$OUT.pasted.txt"; exit 1; }
2805 rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
2806 ok "a paste into nvim keeps its indentation"
2807
2735 # --- M14: the ssh→QUIC handoff ----------------------------------------- 2808 # --- M14: the ssh→QUIC handoff -----------------------------------------
2736 # 2809 #
2737 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and 2810 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and
@@ -3737,7 +3810,7 @@ DPID=""
3737 3810
3738 # The pins. Literals, not variables set from counting something else — 3811 # The pins. Literals, not variables set from counting something else —
3739 # "assert the literal, never the constant the code under test reads" 3812 # "assert the literal, never the constant the code under test reads"
3740 # (decisions.md, M10). 27 scenario checkpoints; 35 convergence points. 3813 # (decisions.md, M10). 28 scenario checkpoints; 35 convergence points.
3741 # Anyone adding a scenario updates these by hand, on purpose. 3814 # Anyone adding a scenario updates these by hand, on purpose.
3742 # 3815 #
3743 # M18 added three checkpoints and no convergence points: its wall block 3816 # M18 added three checkpoints and no convergence points: its wall block
@@ -3745,9 +3818,11 @@ DPID=""
3745 # CONV_COUNT (see its comment) — that pin counts assert_converged call 3818 # CONV_COUNT (see its comment) — that pin counts assert_converged call
3746 # sites, and folding the two together would make either number stop 3819 # sites, and folding the two together would make either number stop
3747 # meaning anything. The side-channel leg added the 27th and no convergence 3820 # meaning anything. The side-channel leg added the 27th and no convergence
3748 # point: its capture is asserted on for a byte the grid does not carry. 3821 # point: its capture is asserted on for a byte the grid does not carry. The
3749 [ "$OK_COUNT" = "27" ] || { 3822 # paste leg added the 28th and no convergence point either: what it asserts
3750 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 27 —" 3823 # on is a file an editor wrote, not a grid at all.
3824 [ "$OK_COUNT" = "28" ] || {
3825 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 28 —"
3751 echo " a scenario was added (update the pin) or silently lost" 3826 echo " a scenario was added (update the pin) or silently lost"
3752 exit 1 3827 exit 1
3753 } 3828 }
@@ -3755,4 +3830,4 @@ DPID=""
3755 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 3830 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
3756 exit 1 3831 exit 1
3757 } 3832 }
3758 echo "e2e OK (27 scenarios, 35 convergence points)" 3833 echo "e2e OK (28 scenarios, 35 convergence points)"
test/ptyclient.zig
Old New
@@ -39,8 +39,58 @@ const Expecter = struct {
39 } 39 }
40 }; 40 };
41 41
42 /// Whether the client under test has put this "terminal" into bracketed
43 /// paste. Read off the same bytes the Expecter accumulates, because that
44 /// buffer is exactly what a real terminal would have received.
45 var bracketed_paste = false;
46 /// How far into that buffer noteModes has already looked.
47 var modes_scanned: usize = 0;
48
49 const BP_SET = "\x1b[?2004h";
50 const BP_RESET = "\x1b[?2004l";
51
52 /// Track the mode over the WHOLE capture rather than one chunk at a time,
53 /// for two reasons a per-chunk scan gets wrong:
54 ///
55 /// * both escapes can land in one read — nvim sets the mode and clears it
56 /// again either side of a shell escape, and 4096 bytes swallow both. The
57 /// mode the terminal is left in is whichever came LAST, not whichever
58 /// `indexOf` the author happened to write second.
59 /// * a read can split the 8-byte escape down the middle, and neither half
60 /// matches anything. Backing the scan up by 7 covers every such split,
61 /// and rescanning bytes is harmless: last-occurrence is idempotent.
62 fn noteModes(buf: []const u8) void {
63 const from = modes_scanned -| (BP_SET.len - 1);
64 const win = buf[from..];
65 const set = std.mem.lastIndexOf(u8, win, BP_SET);
66 const reset = std.mem.lastIndexOf(u8, win, BP_RESET);
67 if (set) |s| {
68 bracketed_paste = if (reset) |r| s > r else true;
69 } else if (reset != null) {
70 bracketed_paste = false;
71 }
72 modes_scanned = buf.len;
73 }
74
75 /// ONE write, asserted: the client's scroll-key parser exact-matches a whole
76 /// read, so a short write here would silently turn one keystroke into two.
77 /// A bracketed paste goes the same way — its markers and its text are one
78 /// paste to the application, not three reads for the parser to race over.
79 fn writeWhole(master: std.posix.fd_t, bytes: []const u8, verb_no: usize) void {
80 const n = std.posix.write(master, bytes) catch |e|
81 fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) });
82 if (n != bytes.len)
83 fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len });
84 }
85
42 const Verb = union(enum) { 86 const Verb = union(enum) {
43 send: []u8, 87 send: []u8,
88 /// Like `send`, but wrapped in bracketed-paste markers IF the client has
89 /// asked this "terminal" for them. That condition is the whole point: the
90 /// fixture behaves the way a real terminal behaves rather than asserting
91 /// what the test wishes were true, so a scenario using `paste` fails when
92 /// the mode mirror regresses.
93 paste: []u8,
44 expect: struct { needle: []u8, deadline_ms: u64 }, 94 expect: struct { needle: []u8, deadline_ms: u64 },
45 resize: struct { cols: u16, rows: u16 }, 95 resize: struct { cols: u16, rows: u16 },
46 settle: struct { quiet_ms: u64, deadline_ms: u64 }, 96 settle: struct { quiet_ms: u64, deadline_ms: u64 },
@@ -51,7 +101,7 @@ const Verb = union(enum) {
51 /// correct without anyone remembering to revisit it. 101 /// correct without anyone remembering to revisit it.
52 fn deinit(self: Verb, alloc: std.mem.Allocator) void { 102 fn deinit(self: Verb, alloc: std.mem.Allocator) void {
53 switch (self) { 103 switch (self) {
54 .send => |s| alloc.free(s), 104 .send, .paste => |s| alloc.free(s),
55 .expect => |e| alloc.free(e.needle), 105 .expect => |e| alloc.free(e.needle),
56 .resize, .settle, .waitexit => {}, 106 .resize, .settle, .waitexit => {},
57 } 107 }
@@ -71,6 +121,8 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
71 const rest = line[sp + 1 ..]; 121 const rest = line[sp + 1 ..];
72 if (std.mem.eql(u8, verb, "send")) { 122 if (std.mem.eql(u8, verb, "send")) {
73 return .{ .send = try decodeEscapes(alloc, rest) }; 123 return .{ .send = try decodeEscapes(alloc, rest) };
124 } else if (std.mem.eql(u8, verb, "paste")) {
125 return .{ .paste = try decodeEscapes(alloc, rest) };
74 } else if (std.mem.eql(u8, verb, "expect")) { 126 } else if (std.mem.eql(u8, verb, "expect")) {
75 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb; 127 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb;
76 // An empty needle matches instantly at any cursor — a check that 128 // An empty needle matches instantly at any cursor — a check that
@@ -139,6 +191,7 @@ fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter)
139 if (n == 0) return false; 191 if (n == 0) return false;
140 try out.writeAll(buf[0..n]); 192 try out.writeAll(buf[0..n]);
141 try exp.feed(alloc, buf[0..n]); 193 try exp.feed(alloc, buf[0..n]);
194 noteModes(exp.buf.items);
142 } 195 }
143 } 196 }
144 197
@@ -236,14 +289,18 @@ pub fn main() !void {
236 defer verb.deinit(alloc); 289 defer verb.deinit(alloc);
237 verb_no += 1; 290 verb_no += 1;
238 switch (verb) { 291 switch (verb) {
239 .send => |bytes| { 292 .send => |bytes| writeWhole(pty.master, bytes, verb_no),
240 // ONE write, asserted: the client's scroll-key parser 293 .paste => |text| {
241 // exact-matches a whole read, so a short write here would 294 // Bracket ONLY when this "terminal" has been told to. A
242 // silently turn one keystroke into two. 295 // fixture that always bracketed would pass whether or not
243 const n = std.posix.write(pty.master, bytes) catch |e| 296 // the client ever mirrored the mode, which is the one thing
244 fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) }); 297 // a paste scenario exists to measure.
245 if (n != bytes.len) 298 const framed = if (bracketed_paste)
246 fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len }); 299 try std.mem.concat(alloc, u8, &.{ "\x1b[200~", text, "\x1b[201~" })
300 else
301 try alloc.dupe(u8, text);
302 defer alloc.free(framed);
303 writeWhole(pty.master, framed, verb_no);
247 }, 304 },
248 .expect => |x| { 305 .expect => |x| {
249 const start = std.time.milliTimestamp(); 306 const start = std.time.milliTimestamp();
@@ -436,6 +493,43 @@ test "parseLine: verbs, spaces in payloads, comments" {
436 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later")); 493 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect two words later"));
437 } 494 }
438 495
496 test "parseLine: paste is a verb and its payload keeps its spaces" {
497 const alloc = std.testing.allocator;
498 const v = (try parseLine(alloc, "paste a = 1")).?;
499 defer v.deinit(alloc);
500 try std.testing.expectEqualStrings("a = 1", v.paste);
501 }
502
503 test "noteModes: the LAST escape in the window wins, split reads included" {
504 bracketed_paste = false;
505 modes_scanned = 0;
506 defer {
507 bracketed_paste = false;
508 modes_scanned = 0;
509 }
510
511 noteModes("nvim starting \x1b[?2004h");
512 try std.testing.expect(bracketed_paste);
513
514 // Both in one read, set last: a per-chunk scan whose reset check ran
515 // second would report the opposite.
516 noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h");
517 try std.testing.expect(bracketed_paste);
518
519 noteModes("nvim starting \x1b[?2004h ...shell out \x1b[?2004l back \x1b[?2004h quit \x1b[?2004l");
520 try std.testing.expect(!bracketed_paste);
521
522 // Split down the middle of the escape: neither half matches on its own,
523 // and only the backed-up rescan sees the whole of it.
524 bracketed_paste = false;
525 modes_scanned = 0;
526 const whole = "abc\x1b[?2004habc";
527 noteModes(whole[0 .. whole.len - 5]); // cuts inside the escape
528 try std.testing.expect(!bracketed_paste);
529 noteModes(whole);
530 try std.testing.expect(bracketed_paste);
531 }
532
439 // Forces semantic analysis of every pub decl under `zig build test`, so an 533 // Forces semantic analysis of every pub decl under `zig build test`, so an
440 // unreferenced decl must at least compile (the silent-module-loss hazard, 534 // unreferenced decl must at least compile (the silent-module-loss hazard,
441 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 535 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.