a73x

6f9f4886

test: pin cli side-channel atomicity

a73x   2026-08-18 12:27

Commit message
test: pin cli side-channel atomicity

src/client.zig
Old New
@@ -1310,38 +1310,24 @@ fn session(
1310 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); 1310 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd);
1311 }, 1311 },
1312 .term_event, .term_modes => { 1312 .term_event, .term_modes => {
1313 // Repeats are expected, not a bug to filter. The daemon
1314 // sends this from `sendResync`, whose only two callers
1315 // are attach arms, so the repeats are attaches and
1316 // nothing else: the first attach, a reconnect, and the
1317 // re-attach a rejected delta forces. NOT a resize —
1318 // `.resize` calls `resyncSnapshot`, which sends only
1319 // snapshots.
1320 //
1321 // Harmless because 2004 is a level-set boolean with no
1322 // counter or stack behind it — mux never writes
1323 // `?2004s`/`?2004r` — so a redundant set is a no-op. The
1324 // repeat that would hurt, a `?2004l` landing between a
1325 // paste's `200~` and its `201~` and stranding the
1326 // terminal without a terminator, cannot arise: a repeat
1327 // always carries the CURRENT value, and mid-paste that
1328 // value is true.
1329 //
1330 // That argument covers repeats only, and the other half
1331 // is unfixable rather than handled: an application that
1332 // genuinely disables bracketed paste mid-paste produces
1333 // a real term_modes(false), and relaying it strands the
1334 // terminal exactly that way. The daemon told the truth
1335 // and we passed it on — there is no better move here.
1336 switch (semantic_core.receive(frame.type, frame.payload)) { 1313 switch (semantic_core.receive(frame.type, frame.payload)) {
1337 .ignored => {}, 1314 .ignored => {},
1338 .state => |state| try writeSideChannel( 1315 .state => |state| {
1339 alloc, 1316 // Mode samples are deliberately not deduplicated.
1340 stdout_fd, 1317 // sendResync repeats the current level on attach,
1341 alt_screen, 1318 // reconnect and forced re-attach; reasserting
1342 state, 1319 // DECSET/DECRST 2004 is a harmless level-set and
1343 appendTermState, 1320 // restores the host after a new connection.
1344 ), 1321 // Occurrence effects take the separate arm below
1322 // and are never covered by this repeat policy.
1323 try writeSideChannel(
1324 alloc,
1325 stdout_fd,
1326 alt_screen,
1327 state,
1328 appendTermState,
1329 );
1330 },
1345 .effect => |effect| try writeSideChannel( 1331 .effect => |effect| try writeSideChannel(
1346 alloc, 1332 alloc,
1347 stdout_fd, 1333 stdout_fd,
@@ -3005,6 +2991,84 @@ test "client: a clipboard effect at the cap retains its exact framing" {
3005 } 2991 }
3006 } 2992 }
3007 2993
2994 fn appendNothing(
2995 _: *std.ArrayList(u8),
2996 _: std.mem.Allocator,
2997 _: void,
2998 ) std.mem.Allocator.Error!void {}
2999
3000 test "client: side channels write nothing before terminal ownership" {
3001 const pipe = try std.posix.pipe();
3002 defer std.posix.close(pipe[0]);
3003 var write_open = true;
3004 defer if (write_open) std.posix.close(pipe[1]);
3005
3006 try writeSideChannel(
3007 std.testing.allocator,
3008 pipe[1],
3009 false,
3010 client_core.Effect{ .bell = {} },
3011 appendHostEffect,
3012 );
3013 std.posix.close(pipe[1]);
3014 write_open = false;
3015
3016 var byte: [1]u8 = undefined;
3017 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
3018 }
3019
3020 test "client: an empty side-channel rendering writes nothing" {
3021 const pipe = try std.posix.pipe();
3022 defer std.posix.close(pipe[0]);
3023 var write_open = true;
3024 defer if (write_open) std.posix.close(pipe[1]);
3025
3026 try writeSideChannel(
3027 std.testing.allocator,
3028 pipe[1],
3029 true,
3030 {},
3031 appendNothing,
3032 );
3033 std.posix.close(pipe[1]);
3034 write_open = false;
3035
3036 var byte: [1]u8 = undefined;
3037 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
3038 }
3039
3040 test "client: an allocation failure discards a partially built side channel" {
3041 const pipe = try std.posix.pipe();
3042 defer std.posix.close(pipe[0]);
3043
3044 // The OSC introducer gets the first allocation. Growing for the payload
3045 // then fails both its resize and allocation fallback, after real escape
3046 // bytes exist in writeSideChannel's private buffer.
3047 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
3048 .fail_index = 1,
3049 .resize_fail_index = 0,
3050 });
3051 var payload: [128]u8 = undefined;
3052 @memset(&payload, 'A');
3053 const result = writeSideChannel(
3054 failing.allocator(),
3055 pipe[1],
3056 true,
3057 client_core.Effect{ .clipboard_set = .{
3058 .target = 'c',
3059 .base64 = &payload,
3060 } },
3061 appendHostEffect,
3062 );
3063 std.posix.close(pipe[1]);
3064
3065 try std.testing.expectError(error.OutOfMemory, result);
3066 try std.testing.expectEqual(@as(usize, 1), failing.allocations);
3067 try std.testing.expect(failing.has_induced_failure);
3068 var byte: [1]u8 = undefined;
3069 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
3070 }
3071
3008 // Forces semantic analysis of every pub decl under `zig build test`, so an 3072 // Forces semantic analysis of every pub decl under `zig build test`, so an
3009 // unreferenced decl must at least compile (the silent-module-loss hazard, 3073 // unreferenced decl must at least compile (the silent-module-loss hazard,
3010 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 3074 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test/e2e.sh
Old New
@@ -2779,8 +2779,8 @@ set -e
2779 # are the same failure line. 2779 # are the same failure line.
2780 grep -qa 'CLIPDONE' "$OUT.clip" || { 2780 grep -qa 'CLIPDONE' "$OUT.clip" || {
2781 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; } 2781 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
2782 # Terminator included: the client builds the escape whole or writes nothing 2782 # Terminator included: appendHostEffect builds into writeSideChannel's private
2783 # at all (client.zig, appendTermEvent), and a needle stopping at the payload 2783 # buffer, which is written whole or not at all; a needle stopping at the payload
2784 # could not tell a complete escape from half of one left painting on the 2784 # could not tell a complete escape from half of one left painting on the
2785 # user's tty. -F because the needle is bytes, not a pattern. 2785 # user's tty. -F because the needle is bytes, not a pattern.
2786 CLIPESC=$(printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007') 2786 CLIPESC=$(printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007')
@@ -3032,13 +3032,13 @@ ok "a paste into nvim keeps its indentation"
3032 3032
3033 # --- side channel: a burst of bells reaches the host as exactly one ------ 3033 # --- side channel: a burst of bells reaches the host as exactly one ------
3034 # 3034 #
3035 # This leg exists for the COALESCING, not for the bell. The client's 3035 # This leg exists for the COALESCING, not for the bell. The shared client core
3036 # term_event dispatch is kind-agnostic — every event goes through one 3036 # decodes every term_event, then every accepted effect follows the same
3037 # writeSideChannel call with no per-kind branch — so the clipboard leg above 3037 # writeSideChannel → appendHostEffect path, so the clipboard leg above already
3038 # already proves frame → decode → builder → host tty for the whole frame 3038 # proves frame → decode → adapter → host tty for the whole frame type. A bell
3039 # type, and a bell that merely ARRIVED would add no coverage: the one 3039 # that merely ARRIVED would add no coverage: the bell-specific adapter line is
3040 # bell-specific line past the wire is pinned directly in client.zig. What 3040 # pinned directly in client.zig. What only e2e can see is the NUMBER. Five rings
3041 # only e2e can see is the NUMBER. Five rings inside one pty chunk must reach 3041 # inside one pty chunk must reach
3042 # the host as one (server.zig, drainSideEvents), and a regression there is 3042 # the host as one (server.zig, drainSideEvents), and a regression there is
3043 # silent — every assertion anyone would think to write about a bell still 3043 # silent — every assertion anyone would think to write about a bell still
3044 # passes while 256 frames per chunk go out. 3044 # passes while 256 frames per chunk go out.
test/xversion.sh
Old New
@@ -444,7 +444,7 @@ SCRIPT
444 # reasonable and would retire that instrument silently. The clipboard's is 444 # reasonable and would retire that instrument silently. The clipboard's is
445 # a transform mux CANNOT avoid: `TermEvent.clipboard` carries a target and 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 446 # base64 and has no field for a terminator, so the session's ST cannot
447 # survive the wire, and `appendTermEvent` appends 0x07 unconditionally 447 # survive the wire, and `appendHostEffect` appends 0x07 unconditionally
448 # (client.zig). Only a protocol change could break that one. So if one of 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. 449 # these needles has to be trusted, trust the clipboard's.
450 # 450 #