6f9f4886
test: pin cli side-channel atomicity
a73x 2026-08-18 12:27
Commit message
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 | # |