a73x

afd3bd11

refactor: the daemon's tests name what they pin, in two lines

a73x   2026-08-30 20:18

Commit message
refactor: the daemon's tests name what they pin, in two lines

71 essays to ~18 across five test files. Each block still says what the
test would miss if it were written the obvious way — an awaitFrame pair
that drops the replay it is looking for, a fixture whose bell races the
attach, a pin that must run first because a wedged step prints nothing.
The retellings of how each was found are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

docscheck.blocks
Old New
@@ -29,13 +29,13 @@ server_agent.zig 3
29 server_sessions.zig 2 29 server_sessions.zig 2
30 server_test_agent.zig 6 30 server_test_agent.zig 6
31 server_test_attach.zig 5 31 server_test_attach.zig 5
32 server_test_await.zig 16 32 server_test_await.zig 3
33 server_test_clipboard.zig 20 33 server_test_clipboard.zig 6
34 server_test_deliver.zig 0 34 server_test_deliver.zig 0
35 server_test_harness.zig 7 35 server_test_harness.zig 3
36 server_test_modes.zig 11 36 server_test_modes.zig 2
37 server_test_quic.zig 6 37 server_test_quic.zig 6
38 server_test_session.zig 18 38 server_test_session.zig 5
39 server_test_upgrade.zig 1 39 server_test_upgrade.zig 1
40 server.zig 17 40 server.zig 17
41 shellint.zig 1 41 shellint.zig 1
src/server/server_test_await.zig
Old New
@@ -19,13 +19,9 @@ test "Server: OSC 133 marks reach attached clients as cmd_state pushes" {
19 const sock_path = try std.fmt.allocPrint(alloc, "{s}/marks.sock", .{dir_path}); 19 const sock_path = try std.fmt.allocPrint(alloc, "{s}/marks.sock", .{dir_path});
20 defer alloc.free(sock_path); 20 defer alloc.free(sock_path);
21 21
22 // A scripted session rather than an interactive shell, for the same 22 // A SCRIPT rather than an interactive shell: an integrated shell emits marks
23 // reason the pty-mode test uses one: an interactive shell emits marks 23 // continuously, so "which push came from which mark" would be a guess. It
24 // only once somebody has installed shell integration into it, and then 24 // ends on a blocking read, so `deinit`'s SIGTERM lands on the shell itself.
25 // emits them continuously. This child emits one mark when told to and
26 // none otherwise, so "which push came from which mark" is not a guess.
27 // It ends on a blocking read so deinit's SIGTERM lands on the shell
28 // itself and leaves nothing running behind the test.
29 try tmp.dir.writeFile(.{ 25 try tmp.dir.writeFile(.{
30 .sub_path = "marks.sh", 26 .sub_path = "marks.sh",
31 .data = 27 .data =
@@ -73,28 +69,22 @@ test "Server: OSC 133 marks reach attached clients as cmd_state pushes" {
73 try std.testing.expectEqual(proto.Mechanism.marks, returned.mechanism); 69 try std.testing.expectEqual(proto.Mechanism.marks, returned.mechanism);
74 try std.testing.expectEqual(@as(?u8, 0), returned.exit_code); 70 try std.testing.expectEqual(@as(?u8, 0), returned.exit_code);
75 71
76 // The seq field is a last-return watermark, not a stamp on the frame 72 // The seq is a last-return WATERMARK, not a stamp on the frame carrying it,
77 // carrying it: it answers "a return happened at or before this seq". 73 // so the running push of the first command a session runs says 0. A consumer
78 // So the running push of the first command this session ever ran says 74 // reading it as "when this command started" reads about a different event.
79 // 0 — nothing has returned yet — and a consumer reading it as "when
80 // this command started" would be reading about a different event.
81 try std.testing.expectEqual(@as(u64, 0), running.seq); 75 try std.testing.expectEqual(@as(u64, 0), running.seq);
82 // The return moves the watermark to the tracker as sampled after the 76 // The return moves the watermark to the tracker as sampled AFTER the update
83 // update for its own pty chunk, so it covers the command's own output. 77 // for its own chunk, so it covers the command's output. Ordering only: a C
84 // Ordering only: a C and a D arriving in one read legitimately share a 78 // and a D in one read legitimately share a seq, hence `>=`.
85 // seq, so this is >= and not >, and the upper bound is the tracker as
86 // it stands now.
87 try std.testing.expect(returned.seq >= running.seq); 79 try std.testing.expect(returned.seq >= running.seq);
88 try std.testing.expect(returned.seq <= srv.sessions.table[0].?.tracker.seq); 80 try std.testing.expect(returned.seq <= srv.sessions.table[0].?.tracker.seq);
89 try std.testing.expect(returned.seq > 0); 81 try std.testing.expect(returned.seq > 0);
90 } 82 }
91 83
92 // --------------------------------------------------------------------------- 84 // ---------------------------------------------------------------------------
93 // Shell integration, end to end. Everything above this line proves the daemon 85 // Shell integration, end to end. Above this line the daemon can READ marks;
94 // can read marks; these two prove a real shell EMITS them, which is the only 86 // these two prove a real shell EMITS them. The chain is injection → shell →
95 // version of the claim that matters in a session. The chain under test is 87 // pty → engine → tracker → wire, and no part of it is stubbed.
96 // injection -> shell -> pty -> engine -> tracker -> wire, and no part of it is
97 // stubbed.
98 // --------------------------------------------------------------------------- 88 // ---------------------------------------------------------------------------
99 89
100 /// The absence half of the phantom-mark assertions: run nothing, claim 90 /// The absence half of the phantom-mark assertions: run nothing, claim
@@ -121,11 +111,9 @@ fn anyReturnWithin(
121 return null; 111 return null;
122 } 112 }
123 113
124 /// A session whose HOME is a directory this test wrote, so the verdict does 114 /// A session whose HOME is a directory this test wrote, so no verdict depends
125 /// not depend on whose rc files the box carries. The rc it plants is not 115 /// on whose rc files the box carries. The rc is NOT empty: it adds a
126 /// empty: it adds a PROMPT_COMMAND member of its own, which is the shape 116 /// PROMPT_COMMAND member, the shape that breaks the bash shim, plus a PS1.
127 /// that broke the bash shim (a member firing the DEBUG trap on every idle
128 /// prompt cycle), and an unmistakable PS1 to wait on.
129 const IntegratedSession = struct { 117 const IntegratedSession = struct {
130 tmp: TmpDir, 118 tmp: TmpDir,
131 home: [:0]const u8, 119 home: [:0]const u8,
@@ -159,14 +147,10 @@ const IntegratedSession = struct {
159 .shell_integration = true, 147 .shell_integration = true,
160 .extra_env = &.{ 148 .extra_env = &.{
161 .{ .key = "HOME", .value = self.home }, 149 .{ .key = "HOME", .value = self.home },
162 // Emptied, and that is not cosmetic. shellint.prepare copies 150 // Emptied, and not cosmetically: the shim copies the DAEMON's
163 // the DAEMON's ZDOTDIR into MUX_ORIG_ZDOTDIR, and here the 151 // ZDOTDIR, and here the daemon is the test runner — so a runner
164 // daemon is the test runner — so on a box whose runner has 152 // with ZDOTDIR set would source THAT .zshrc, past the planted
165 // ZDOTDIR set, the zsh shim would restore it and source THAT 153 // HOME. Empty reads as "there was none". `extra_env` wins.
166 // .zshrc, sailing straight past the HOME planted above. The
167 // shim reads an empty value as "there was none" and falls
168 // back to $HOME, which is the hermetic answer. extra_env is
169 // applied after the injection's own pairs, so this wins.
170 .{ .key = "MUX_ORIG_ZDOTDIR", .value = "" }, 154 .{ .key = "MUX_ORIG_ZDOTDIR", .value = "" },
171 }, 155 },
172 }); 156 });
@@ -237,14 +221,10 @@ test "Server: bash emits one mark pair per command, and none at an idle prompt"
237 // live shell rather than about a slow one. 221 // live shell rather than about a slow one.
238 try std.testing.expect(try awaitGridText(alloc, &s.srv, IntegratedSession.prompt, 10_000)); 222 try std.testing.expect(try awaitGridText(alloc, &s.srv, IntegratedSession.prompt, 10_000));
239 223
240 // NOTHING has been asked to run, so nothing may claim to have returned. 224 // NOTHING has been asked to run, so nothing may claim to have returned. A
241 // 225 // DEBUG trap armed one line before the PROMPT_COMMAND assignment fires ON
242 // This is the regression that shipped: the DEBUG trap was armed one line 226 // that assignment, and the very first prompt reports `D;0` for a command
243 // before the PROMPT_COMMAND assignment, so it fired ON that assignment 227 // that never existed.
244 // and the very first prompt reported `D;0` for a command that never
245 // existed. Measured on a real bash before the fix, the session opened
246 // {C:PROMPT_COMMAND=...}{D;0}{A} — and the earlier version of this test
247 // was satisfied by that phantom rather than by the command it sent.
248 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 400)) |ghost| { 228 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 400)) |ghost| {
249 std.debug.print("phantom return at the opening prompt: {any}\n", .{ghost}); 229 std.debug.print("phantom return at the opening prompt: {any}\n", .{ghost});
250 return error.PhantomReturnBeforeAnyCommand; 230 return error.PhantomReturnBeforeAnyCommand;
@@ -259,11 +239,9 @@ test "Server: bash emits one mark pair per command, and none at an idle prompt"
259 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism); 239 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism);
260 try std.testing.expectEqual(@as(?u8, 1), st.exit_code); 240 try std.testing.expectEqual(@as(?u8, 1), st.exit_code);
261 241
262 // A bare Enter runs PROMPT_COMMAND again and no command at all. Its 242 // A bare Enter runs PROMPT_COMMAND again and no command at all. Its members
263 // members fire the DEBUG trap, and before the membership check they 243 // fire the DEBUG trap, so without the membership check an untouched prompt
264 // re-armed the shim — so an untouched prompt reported a successful 244 // reports a successful command on every cycle.
265 // command on every cycle. Three Enters produced four phantom `D;0`s on
266 // a real bash; the correct answer is none.
267 try proto.writeFrame(s.conn.handle, .input, "\n\n\n"); 245 try proto.writeFrame(s.conn.handle, .input, "\n\n\n");
268 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 600)) |ghost| { 246 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 600)) |ghost| {
269 std.debug.print("phantom return at an idle prompt: {any}\n", .{ghost}); 247 std.debug.print("phantom return at an idle prompt: {any}\n", .{ghost});
@@ -443,21 +421,17 @@ test "Server: an unknown shell is not injected into at all — no directory, no
443 } 421 }
444 422
445 // --------------------------------------------------------------------------- 423 // ---------------------------------------------------------------------------
446 // Awaits: a request the daemon holds open until something answers it. 424 // Awaits: a request the daemon holds open until something answers it. Every
447 // 425 // test below asserts the REASON an await ended, never how long it took —
448 // Every test below asserts the REASON an await ended, never how long it took. 426 // latency here is the run loop's granularity crossed with the machine's load.
449 // Latency here is the run loop's sampling granularity crossed with whatever 427 // The iteration budgets are outer bounds, finite so a regression fails by name
450 // else the machine is doing, so a test that pinned it would be pinning the
451 // box. The iteration budgets are outer bounds — generous enough that a loaded
452 // box does not read as a regression, finite so a regression fails by name
453 // instead of hanging the suite. 428 // instead of hanging the suite.
454 // --------------------------------------------------------------------------- 429 // ---------------------------------------------------------------------------
455 430
456 /// Read frames already sitting on `fd` — deliberately without pumping — until 431 /// Read frames already sitting on `fd`, deliberately WITHOUT pumping, until one
457 /// one of `want` turns up or the socket goes quiet. This is the "did the 432 /// of `want` turns up or the socket goes quiet — the "did the daemon answer
458 /// daemon answer inside that one pump" observable: an answer that needs 433 /// inside that one pump" observable. An answer needing another pump reads as
459 /// another pump to appear reads as absent here, which is the distinction the 434 /// absent, which is the distinction the immediate-answer test makes.
460 /// immediate-answer test exists to make.
461 fn readQueued(alloc: std.mem.Allocator, fd: std.posix.fd_t, want: proto.MsgType) !?proto.Frame { 435 fn readQueued(alloc: std.mem.Allocator, fd: std.posix.fd_t, want: proto.MsgType) !?proto.Frame {
462 var guard: usize = 0; 436 var guard: usize = 0;
463 while (guard < 16) : (guard += 1) { 437 while (guard < 16) : (guard += 1) {
@@ -481,17 +455,12 @@ test "Server: an await is held open, answered by a mark, and re-answered immedia
481 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitmark.sock", .{dir_path}); 455 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitmark.sock", .{dir_path});
482 defer alloc.free(sock_path); 456 defer alloc.free(sock_path);
483 457
484 // Script-gated for the same reason the cmd_state push test is: the child 458 // Script-gated, so there is a moment at which "no reply yet" is a claim
485 // emits its marks when told to and never before, so there is a moment at 459 // worth making. The BURST is the point of its shape: real integration writes
486 // which "no reply yet" is a claim worth making. Against a real integrated 460 // `D;code` and the next prompt's `A` together, so both fold into the tracker
487 // shell there would not be. 461 // inside one pty read and the phase is back at `at_prompt` before any await
488 // 462 // is examined. Stopping at the D passes an implementation that could only
489 // The burst is the whole point of its shape. Real integration writes 463 // answer in the sliver between the two.
490 // `D;code` and the next prompt's `A` together from precmd, so both fold
491 // into the tracker inside one pty read and the phase is back at at_prompt
492 // before any await is examined. An earlier version of this test stopped
493 // at the D and passed against an implementation that could only answer in
494 // the sliver between the two — which is to say, never, in production.
495 try tmp.dir.writeFile(.{ 464 try tmp.dir.writeFile(.{
496 .sub_path = "await.sh", 465 .sub_path = "await.sh",
497 .data = 466 .data =
@@ -542,14 +511,10 @@ test "Server: an await is held open, answered by a mark, and re-answered immedia
542 try std.testing.expectEqual(proto.Mechanism.marks, rep.state.mechanism); 511 try std.testing.expectEqual(proto.Mechanism.marks, rep.state.mechanism);
543 try std.testing.expectEqual(@as(?u8, 3), rep.state.exit_code); 512 try std.testing.expectEqual(@as(?u8, 3), rep.state.exit_code);
544 513
545 // Reconnect idempotency, asked after the burst's A has already landed and 514 // Reconnect idempotency: an agent whose answer died with its connection
546 // put the session back at a prompt: an agent whose answer died with its 515 // re-asks with the seq it last held, and that return has already happened —
547 // connection re-asks with the seq it last held. That return has already 516 // so the daemon answers INLINE, in the very pump that read the request.
548 // happened, so the daemon must not make it wait for a second one — it 517 // Reading without pumping again is what makes "inline" the assertion.
549 // answers inline, in the very pump that read the request. Reading without
550 // pumping again is what makes "inline" the thing being asserted, and the
551 // window in which this can be answered is the rest of the session rather
552 // than the sliver between a D and the A that follows it.
553 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{ 518 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
554 .since_seq = 0, 519 .since_seq = 0,
555 .settle_ms = 0, 520 .settle_ms = 0,
@@ -564,13 +529,10 @@ test "Server: an await is held open, answered by a mark, and re-answered immedia
564 try std.testing.expectEqual(proto.Mechanism.marks, rep2.state.mechanism); 529 try std.testing.expectEqual(proto.Mechanism.marks, rep2.state.mechanism);
565 try std.testing.expectEqual(@as(?u8, 3), rep2.state.exit_code); 530 try std.testing.expectEqual(@as(?u8, 3), rep2.state.exit_code);
566 531
567 // And a client attaching only now is told the state it could not have 532 // A client attaching only now is told the state it could not have witnessed,
568 // witnessed, rather than learning nothing until the next transition. 533 // rather than learning nothing until the next transition. The EXIT CODE
569 // That state is the LIVE one, which after the burst's A really is 534 // carries the news: an untouched tracker reports null, so 3 means this push
570 // at_prompt — a push describes where the session is, and the session is 535 // came from the command that ran.
571 // at a prompt. The exit code is what carries the news, and it is the one
572 // a fresh session could not produce: null is what an untouched tracker
573 // reports, so 3 here means this push came from the command that ran.
574 const late = try std.net.connectUnixSocket(sock_path); 536 const late = try std.net.connectUnixSocket(sock_path);
575 defer late.close(); 537 defer late.close();
576 try proto.writeFrame(late.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); 538 try proto.writeFrame(late.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
@@ -591,11 +553,9 @@ test "Server: a return is still answerable once the next command is running" {
591 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitnext.sock", .{dir_path}); 553 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitnext.sock", .{dir_path});
592 defer alloc.free(sock_path); 554 defer alloc.free(sock_path);
593 555
594 // One command that finishes with code 3 and redraws its prompt, then a 556 // One command that finishes with code 3, then a second that stays running.
595 // second that starts and stays running. The live tracker is describing 557 // The LIVE tracker is describing the second by the time the await is asked,
596 // the second command by the time the await is asked — running, no exit 558 // so anything answered out of it answers about the wrong command.
597 // code, a different start row — so anything answered out of it would be
598 // answering about the wrong command.
599 try tmp.dir.writeFile(.{ 559 try tmp.dir.writeFile(.{
600 .sub_path = "next.sh", 560 .sub_path = "next.sh",
601 .data = 561 .data =
@@ -776,11 +736,9 @@ test "Server: a timed-out await carries no exit code, not the last command's" {
776 try std.testing.expectEqual(proto.AwaitReason.returned, rep.reason); 736 try std.testing.expectEqual(proto.AwaitReason.returned, rep.reason);
777 try std.testing.expectEqual(@as(?u8, 5), rep.state.exit_code); 737 try std.testing.expectEqual(@as(?u8, 5), rep.state.exit_code);
778 738
779 // Now wait for the NEXT return, with the watermark that reply carried — 739 // Wait for the NEXT return with the watermark that reply carried. Nothing
780 // a marks reply, so its seq is the watermark and taking it back is the 740 // else will come: the script is blocked in `read`, never leaves the shell's
781 // documented move. Nothing else will come: the script is blocked in 741 // process group, and no settle floor was asked for.
782 // `read`, it never leaves the shell's own process group, and no settle
783 // floor was asked for. The timeout is the only arm that can fire.
784 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{ 742 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
785 .since_seq = rep.state.seq, 743 .since_seq = rep.state.seq,
786 .settle_ms = 0, 744 .settle_ms = 0,
@@ -811,11 +769,9 @@ test "Server: without shell integration a foreground job's end is caught by the
811 const sock_path = try std.fmt.allocPrint(alloc, "{s}/pgid.sock", .{tmp.path()}); 769 const sock_path = try std.fmt.allocPrint(alloc, "{s}/pgid.sock", .{tmp.path()});
812 defer alloc.free(sock_path); 770 defer alloc.free(sock_path);
813 771
814 // A real interactive /bin/sh with no integration installed. No mark will 772 // A real interactive /bin/sh with NO integration: no mark will ever arrive,
815 // ever arrive, so the only evidence that a command ran and finished is 773 // so the only evidence a command ran is the foreground process group leaving
816 // that the terminal's foreground process group left the shell and came 774 // the shell and coming back.
817 // back — see the fgPgid test in pty.zig for that same movement observed
818 // directly.
819 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); 775 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
820 defer srv.deinit(); 776 defer srv.deinit();
821 777
src/server/server_test_clipboard.zig
Old New
@@ -119,16 +119,13 @@ test "Server: selection extraction statuses map to wire replies" {
119 119
120 // --------------------------------------------------------------------------- 120 // ---------------------------------------------------------------------------
121 // Side-channel events: the engine's OSC 52 and bell, out to the session's own 121 // Side-channel events: the engine's OSC 52 and bell, out to the session's own
122 // clients. The drift pin comes first on purpose — a wedged test below prints 122 // clients. The drift pin comes FIRST, because a wedged test below prints
123 // nothing at all, so the cheap assertion has to be the one that runs first. 123 // nothing at all.
124 // --------------------------------------------------------------------------- 124 // ---------------------------------------------------------------------------
125 125
126 // The layering forbids engine importing protocol (both are layer 0 and both 126 // The layering forbids `engine` importing `protocol`, so the cap is written in
127 // build for wasm32), so the cap is written in two places. 127 // two places and this is what stops them drifting in silence. server.zig is one
128 // This is what stops them drifting apart in silence — the same doctrine as 128 // of the few modules that legitimately imports both.
129 // encodeDebugDumpNamed's "two binaries that can disagree", applied where the
130 // import graph will not allow a single owner. server.zig is one of the few
131 // modules that legitimately imports both.
132 test "the engine's default clipboard cap is the wire's" { 129 test "the engine's default clipboard cap is the wire's" {
133 try std.testing.expectEqual( 130 try std.testing.expectEqual(
134 proto.clipboard_base64_max, 131 proto.clipboard_base64_max,
@@ -144,13 +141,9 @@ test "Server: a clipboard event reaches this session's clients and no others" {
144 const sock_path = try std.fmt.allocPrint(alloc, "{s}/clip.sock", .{tmp.path()}); 141 const sock_path = try std.fmt.allocPrint(alloc, "{s}/clip.sock", .{tmp.path()});
145 defer alloc.free(sock_path); 142 defer alloc.free(sock_path);
146 143
147 // /bin/cat in both sessions, and the OSC 52 arrives as INPUT rather than 144 // The OSC 52 arrives as INPUT rather than from a script: one `SpawnPlan` is
148 // from a script: one SpawnPlan is shared by every session this daemon 145 // shared by every session, so a shell emitting the escape itself emits it in
149 // spawns, so a shell that emits the escape by itself would emit it in 146 // BOTH and the "no others" half could never be observed.
150 // BOTH sessions and the "no others" half could never be observed. Typed
151 // into one session, cat writes it back down that session's pty alone —
152 // which is also the honest path, since the bytes still have to survive
153 // the pump's read arm to become a frame.
154 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 147 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
155 defer srv.deinit(); 148 defer srv.deinit();
156 149
@@ -183,10 +176,8 @@ test "Server: a clipboard event reaches this session's clients and no others" {
183 } 176 }
184 177
185 // The boundary, with the positive already in hand so this cannot pass 178 // The boundary, with the positive already in hand so this cannot pass
186 // vacuously. The budget keeps pumping so a misdirected push, which would 179 // vacuously: the budget keeps pumping, so a misdirected push would have
187 // have been queued in the same pump a's was, has amply arrived — then 180 // arrived by now. A clipboard write belongs to the shell that produced it.
188 // b's client is asserted never to see one. A clipboard write belongs to
189 // the shell that produced it.
190 if (try awaitFrame(alloc, &srv, cb.handle, .term_event, 60)) |leaked| { 181 if (try awaitFrame(alloc, &srv, cb.handle, .term_event, 60)) |leaked| {
191 leaked.deinit(alloc); 182 leaked.deinit(alloc);
192 return error.TermEventCrossedSessions; 183 return error.TermEventCrossedSessions;
@@ -194,28 +185,20 @@ test "Server: a clipboard event reaches this session's clients and no others" {
194 } 185 }
195 186
196 // --------------------------------------------------------------------------- 187 // ---------------------------------------------------------------------------
197 // The gap: a side-channel event produced while nobody was attached. 188 // The gap: a side-channel event produced while nobody was attached. The side
189 // channel follows the grid's own resync verdict — a client that earns a DELTA
190 // was watching continuously and is owed what it missed, while one that gets a
191 // SNAPSHOT is starting fresh and would have its clipboard hijacked.
198 // 192 //
199 // The side channel follows the grid's own resync verdict: a client that earns 193 // The first two tests are OPPOSITES and neither is meaningful alone: one is
200 // a DELTA is one mux believes has been watching this session continuously, so 194 // passed by replaying to everyone, the other by replaying to no one. The third
201 // it is owed what it missed; a client that gets a SNAPSHOT is starting fresh, 195 // pins the boundary, the fourth the expiry no client can observe.
202 // and handing it a twenty-minute-old clipboard write would hijack the user's
203 // clipboard now.
204 //
205 // The first two tests below are opposites on purpose, and neither is
206 // meaningful without the other — the first alone is passed by replaying to
207 // everyone, the second alone by replaying to no one. The third pins the
208 // boundary between them (a client that was THERE for the event is not handed
209 // it again), and the fourth pins the expiry, which no client can observe.
210 // --------------------------------------------------------------------------- 196 // ---------------------------------------------------------------------------
211 197
212 /// The escapes are emitted ON DEMAND because they must land in the gap: after 198 /// The escapes are emitted ON DEMAND because they must land in the gap: after
213 /// one client has gone, before the next arrives. 199 /// one client has gone, before the next arrives. `gap-open` keeps these off the
214 /// 200 /// pty's echo, TWO clipboard sets make "last one wins" observable, the BEL fills
215 /// `gap-open` first keeps these tests off the pty's echo of `go`; TWO 201 /// the second slot, and `after-osc` is the gate.
216 /// clipboard sets make "last one wins" observable; the BEL fills the second
217 /// slot so the bell arm is reached; `after-osc` last is the gate, since one
218 /// pump drains every side event before it.
219 fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 202 fn writeGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
220 try tmp.dir.writeFile(.{ 203 try tmp.dir.writeFile(.{
221 .sub_path = "gap.sh", 204 .sub_path = "gap.sh",
@@ -296,15 +279,10 @@ const GapReplay = struct {
296 bells: usize = 0, 279 bells: usize = 0,
297 /// A bell arrived when a clipboard event had already been seen. 280 /// A bell arrived when a clipboard event had already been seen.
298 bell_after_clip: bool = false, 281 bell_after_clip: bool = false,
299 /// A side-channel event arrived BEFORE the frame that repaints the grid. 282 /// A side-channel event arrived BEFORE the frame that repaints the grid —
300 /// The one ordering `replayPending` calls load-bearing: a bell or a 283 /// the one ordering `replayPending` calls load-bearing, since a terminal ACTS
301 /// clipboard set is something the host terminal acts on, and acting ahead 284 /// on a bell and acting ahead of the repaint dings about a screen nobody can
302 /// of the repaint is dinging about a screen the user cannot see yet. 285 /// see. Here because this loop is the only thing that can see it.
303 ///
304 /// It lives here because this loop is the only thing that can see it.
305 /// `content` records the FIRST content frame, so it stays `.delta` however
306 /// early an event arrives — every other field is blind to the order, and
307 /// swapping `sendDeltaTo` and `replayPending` survived them all.
308 event_before_content: bool = false, 286 event_before_content: bool = false,
309 clip_target: u8 = 0, 287 clip_target: u8 = 0,
310 /// Sized to the fixture, not to the wire cap: `clipboard_base64_max` is 288 /// Sized to the fixture, not to the wire cap: `clipboard_base64_max` is
@@ -318,20 +296,13 @@ const GapReplay = struct {
318 } 296 }
319 }; 297 };
320 298
321 /// Pump until the attach has been answered, then keep pumping a settling 299 /// Pump until the attach has been answered, then keep pumping a settling window
322 /// window past it, collecting every term_event on the way. 300 /// past it, collecting every `term_event` on the way.
323 ///
324 /// One owner for what were three hand-rolled copies of this loop, each
325 /// carrying its own budget that had to stay in step with the others. It also
326 /// fixes, in one place, the shape this branch has now been bitten by twice:
327 /// `awaitFrame` DROPS frames it is not looking for, so "wait for the content
328 /// frame, then watch for a term_event" is blind to an event queued AHEAD of
329 /// the content frame — which is exactly where a replay hoisted out of the
330 /// delta branch lands. Everything is inspected here.
331 /// 301 ///
332 /// The window keeps running past the content frame so an event queued after 302 /// EVERYTHING is inspected, because `awaitFrame` DROPS frames it is not looking
333 /// it is caught too. 400 iterations at ~6ms each is the budget `awaitFrame` 303 /// for — so "wait for the content frame, then watch for a term_event" is blind
334 /// uses for an attach reply. 304 /// to an event queued AHEAD of it, which is exactly where a replay hoisted out
305 /// of the delta branch lands.
335 fn collectGapReplay(alloc: std.mem.Allocator, srv: *Server, fd: std.posix.fd_t) !GapReplay { 306 fn collectGapReplay(alloc: std.mem.Allocator, srv: *Server, fd: std.posix.fd_t) !GapReplay {
336 var out: GapReplay = .{}; 307 var out: GapReplay = .{};
337 var i: usize = 0; 308 var i: usize = 0;
@@ -392,12 +363,9 @@ test "Server: a delta reattach is replayed the gap's last clipboard set, then it
392 defer b.close(); 363 defer b.close();
393 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, wm.seq, wm.epoch)); 364 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, wm.seq, wm.epoch));
394 365
395 // Collected rather than asserted one frame at a time, because four 366 // Collected rather than asserted frame by frame, because four claims ride on
396 // separate claims ride on the SEQUENCE: the delta arm answered, exactly 367 // the SEQUENCE: the delta arm answered, exactly one clipboard event came back,
397 // one clipboard event came back (a slot, not a log), it carries the 368 // it carries the second set, and the bell follows it.
398 // second set (last one wins), and the bell follows it (both kinds
399 // replayed, in enum order). Reading them one at a time would pin the
400 // first and leave the rest to prose.
401 const got = try collectGapReplay(alloc, &srv, b.handle); 369 const got = try collectGapReplay(alloc, &srv, b.handle);
402 370
403 // The grid first, and by branch rather than merely "something arrived": 371 // The grid first, and by branch rather than merely "something arrived":
@@ -405,11 +373,9 @@ test "Server: a delta reattach is replayed the gap's last clipboard set, then it
405 // a screen the user cannot see yet is what the ordering rule prevents. 373 // a screen the user cannot see yet is what the ordering rule prevents.
406 try std.testing.expectEqual(proto.MsgType.delta, got.content orelse 374 try std.testing.expectEqual(proto.MsgType.delta, got.content orelse
407 return error.AttachNeverAnswered); 375 return error.AttachNeverAnswered);
408 // The delta BEFORE the events, which is the one ordering replayPending 376 // The delta BEFORE the events, the one ordering `replayPending` claims
409 // claims matters. Nothing else here can see it: `content` holds the first 377 // matters. Nothing else here can see it — `content` reads `.delta` however
410 // content frame, so it reads `.delta` however early an event arrives, and 378 // early an event arrives.
411 // swapping sendDeltaTo with replayPending survived every other assertion
412 // in this test.
413 try std.testing.expect(!got.event_before_content); 379 try std.testing.expect(!got.event_before_content);
414 try std.testing.expectEqual(@as(usize, 1), got.clips); 380 try std.testing.expectEqual(@as(usize, 1), got.clips);
415 try std.testing.expectEqual(@as(u8, 'c'), got.clip_target); 381 try std.testing.expectEqual(@as(u8, 'c'), got.clip_target);
@@ -434,23 +400,11 @@ test "Server: a reattach quoting the seq an event was stamped at is not replayed
434 400
435 const wm = try clipboardIntoGap(alloc, &srv, sock_path); 401 const wm = try clipboardIntoGap(alloc, &srv, sock_path);
436 402
437 // The `>` in replayPending, at its exact boundary. A client quoting the 403 // The `>` in `replayPending`, at its exact boundary: a client quoting the seq
438 // seq an event was stamped at is one that was THERE for it, and replaying 404 // an event was stamped at was THERE for it, and replaying overwrites whatever
439 // would set its clipboard a second time — silently overwriting whatever 405 // the user has copied since. The stamps are read off the session and the
440 // the user has copied since. Under `>=` every one of these reattaches 406 // HIGHEST is quoted, since the kinds need not share a seq. Walked via
441 // gets a duplicate, and the delta-branch test above cannot see it, 407 // `pendingSlots`, so a third kind cannot sit silently outside the `@max`.
442 // because it quotes a watermark from strictly before the gap.
443 //
444 // The stamps are read off the session rather than guessed at, and the
445 // HIGHEST is what gets quoted: the kinds need not share a seq (the pty
446 // may chunk between them), and quoting a lower one would let the others
447 // through and prove nothing.
448 //
449 // Walked via pendingSlots rather than naming the two fields. Naming them
450 // made this a fifth enumeration of the slot set, and the one place the
451 // compile-time enforcement does not reach: a third kind stamped above
452 // both would have sat silently outside the @max, and this test would have
453 // gone on passing while saying nothing about it.
454 const s = &srv.sessions.table[0].?; 408 const s = &srv.sessions.table[0].?;
455 var at: u64 = 0; 409 var at: u64 = 0;
456 var lowest: u64 = std.math.maxInt(u64); 410 var lowest: u64 = std.math.maxInt(u64);
@@ -461,16 +415,10 @@ test "Server: a reattach quoting the seq an event was stamped at is not replayed
461 at = @max(at, p.seq); 415 at = @max(at, p.seq);
462 lowest = @min(lowest, p.seq); 416 lowest = @min(lowest, p.seq);
463 } 417 }
464 // Every kind that EXISTS must be recorded — `kinds.len`, not "however 418 // Every kind that EXISTS must be recorded — `kinds.len`, not what the fixture
465 // many the fixture happens to emit". The stronger reading is deliberate: 419 // happens to emit — so a kind added later SHOULD fail here. And every stamp
466 // a kind added later that this fixture does not produce SHOULD fail here, 420 // must be above the departed client's watermark, or the absence below passes
467 // because the seq it would be stamped at is exactly what the `@max` above 421 // for the wrong reason.
468 // has to cover. Matching the assertion to the fixture would license
469 // adding a kind that this test then says nothing about.
470 //
471 // And every stamp must be above the departed client's watermark. Without
472 // both, the absence below passes for the wrong reason — nothing to
473 // replay, rather than a refusal to replay.
474 try std.testing.expectEqual(@as(usize, Session.kinds.len), recorded); 422 try std.testing.expectEqual(@as(usize, Session.kinds.len), recorded);
475 try std.testing.expect(lowest > wm.seq); 423 try std.testing.expect(lowest > wm.seq);
476 424
@@ -511,16 +459,10 @@ test "Server: a clipboard event in the gap is not replayed to a snapshot attach"
511 defer b.close(); 459 defer b.close();
512 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); 460 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
513 461
514 // collectGapReplay inspects every frame rather than reaching for 462 // `collectGapReplay` inspects EVERY frame: `awaitFrame` drops what it is not
515 // awaitFrame twice, and that is load-bearing here: awaitFrame DROPS what 463 // looking for, so a replay queued ahead of the snapshot would be swallowed —
516 // it is not looking for, so a replay queued ahead of the snapshot would 464 // and that is exactly where a hoisted replay lands. The snapshot is still
517 // be swallowed on the way past — and ahead of the snapshot is exactly 465 // required, so the absence cannot pass vacuously.
518 // where a replay hoisted out of the delta branch lands. (Verified by
519 // mutation: with an awaitFrame pair, a replayPending call placed above
520 // the snapshotTo passed green.)
521 //
522 // The snapshot is still required, so the absence cannot pass vacuously
523 // against an attach that was never serviced at all.
524 const got = try collectGapReplay(alloc, &srv, b.handle); 466 const got = try collectGapReplay(alloc, &srv, b.handle);
525 try std.testing.expectEqual(proto.MsgType.snapshot, got.content orelse 467 try std.testing.expectEqual(proto.MsgType.snapshot, got.content orelse
526 return error.AttachNeverAnswered); 468 return error.AttachNeverAnswered);
@@ -544,18 +486,11 @@ test "Server: a rebuild drops the pending clipboard it just put out of reach" {
544 486
545 _ = try clipboardIntoGap(alloc, &srv, sock_path); 487 _ = try clipboardIntoGap(alloc, &srv, sock_path);
546 488
547 // An assertion on daemon state rather than on the wire, because the 489 // On daemon STATE rather than the wire, because an expired event and a
548 // property IS about daemon state: an expired event and a retained one 490 // retained one are indistinguishable to every client. A rebuild moves
549 // are indistinguishable to every client. A rebuild moves reset_seq past 491 // `reset_seq` past the recorded seq, so retaining leaves the user's copied
550 // the recorded seq, so no reattach can be delta-served against it ever 492 // text in a daemon that can no longer give it to anyone. Every slot, so a
551 // again — one branch answers by snapshot, the other quotes a seq already 493 // kind added later cannot go unchecked.
552 // above it. What retaining it would leave behind is the user's copied
553 // text sitting in a daemon that can no longer give it to anyone.
554 //
555 // Every slot, via pendingSlots, rather than the clipboard by name: the
556 // rule is about the whole set, and naming one field would let a kind
557 // added later go unchecked here while the test name still claimed the
558 // rebuild drops what it stranded.
559 for (srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null); 494 for (srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
560 495
561 // A joiner at a different size is the shortest route to a rebuild: it 496 // A joiner at a different size is the shortest route to a rebuild: it
@@ -587,28 +522,17 @@ test "Server: the expiry keeps a servable event and drops one a stranded tracker
587 const s = &srv.sessions.table[0].?; 522 const s = &srv.sessions.table[0].?;
588 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null); 523 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
589 524
590 // RETAIN. Nothing has moved, so every stamp is still servable and the 525 // RETAIN: nothing has moved, so every stamp is still servable. This half has
591 // drop must keep all of them. 526 // no other witness — a successful rebuild puts every stamp out of reach, so a
592 // 527 // version that dropped unconditionally would behave identically. The
593 // This half has no other witness, and that is the point of testing it by 528 // predicate only RETAINS where rebuild backs out, under allocation failure.
594 // direct call. On every SUCCESSFUL rebuild the reset_seq jump puts every
595 // stamp out of reach, so the predicate drops everything and a version
596 // that dropped unconditionally would behave identically — the sibling
597 // test above cannot tell them apart. The predicate only ever RETAINS in
598 // the shape where rebuild backs out before touching a field, which is
599 // reachable solely under allocation failure. Deleting `if
600 // (canServe) continue;` left the whole suite green until this line.
601 s.dropUnservablePending(srv.alloc); 529 s.dropUnservablePending(srv.alloc);
602 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null); 530 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
603 531
604 // DROP. `rows == 0` is exactly what a failure inside rebuild's dump loop 532 // DROP. `rows == 0` is what a failure inside rebuild's dump loop leaves
605 // leaves behind, and it makes canServe false for every seq in existence. 533 // behind, and it makes `canServe` false for every seq. Set directly because
606 // 534 // there is no failing-allocator seam into a live Server, so what stays
607 // Set directly because there is no failing-allocator seam into a live 535 // unfalsifiable is the WIRING and no longer the rule.
608 // Server. That is a real limit and worth naming precisely: what stays
609 // unfalsifiable afterwards is only the WIRING — that rebuildTracker's
610 // `defer` reaches this on the failure path — and no longer the rule
611 // itself, which is what the defer's safety argument rests on.
612 s.tracker.rows = 0; 536 s.tracker.rows = 0;
613 s.dropUnservablePending(srv.alloc); 537 s.dropUnservablePending(srv.alloc);
614 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* == null); 538 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* == null);
@@ -625,19 +549,11 @@ test "Server: recordPending stores nothing against a tracker that can serve no s
625 const script = try writeGapShell(alloc, &tmp); 549 const script = try writeGapShell(alloc, &tmp);
626 defer alloc.free(script); 550 defer alloc.free(script);
627 551
628 // No client connects here at all — the session exists because 552 // No client connects at all; the session exists because `Server.init` creates
629 // Server.init creates the default one. 553 // the default one. This cannot reach the guard the natural way: `sendUpdate`
630 // 554 // runs BEFORE the drain on every pty chunk, and a tracker with `rows == 0`
631 // This was meant to reach the guard the natural way, by draining an 555 // resyncs — which rebuilds even with nobody attached. So the FIRST chunk of
632 // escape before any attach had built the tracker. It does not, and the 556 // output builds the tracker whether or not anyone is watching.
633 // reason is worth writing down because it is the opposite of what the
634 // shape of the code suggests: `sendUpdate` runs BEFORE the drain on every
635 // pty chunk, and a tracker with rows == 0 makes its `update` return
636 // `.discontinuity`, which goes straight to `resyncSnapshot` — and that
637 // rebuilds even with nobody attached, deliberately, so the tracker is
638 // ready for the next attach. So the FIRST chunk of output builds the
639 // tracker whether or not anyone is watching, and by the time any escape
640 // is drained `canServe` is already true.
641 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); 557 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
642 defer srv.deinit(); 558 defer srv.deinit();
643 559
@@ -653,11 +569,9 @@ test "Server: recordPending stores nothing against a tracker that can serve no s
653 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null); 569 for (s.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
654 s.freePending(srv.alloc); 570 s.freePending(srv.alloc);
655 571
656 // Now the guard itself, by direct call against a stranded tracker — 572 // The guard itself, by direct call against a stranded tracker. Reachable in a
657 // rows == 0 being what a rebuild that failed inside its dump loop leaves 573 // live daemon only under allocation failure: the rule is testable, the path
658 // behind. Reachable in a live daemon only under allocation failure, which 574 // to it is not.
659 // is why this is a direct call and not a scenario: the rule is testable,
660 // the path to it is not.
661 s.tracker.rows = 0; 575 s.tracker.rows = 0;
662 s.recordPending(srv.alloc, .clipboard, gap_clip_first); 576 s.recordPending(srv.alloc, .clipboard, gap_clip_first);
663 s.recordPending(srv.alloc, .bell, ""); 577 s.recordPending(srv.alloc, .bell, "");
@@ -678,16 +592,10 @@ test "Server: a session that dies holding a pending event frees it" {
678 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); 592 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
679 defer srv.deinit(); 593 defer srv.deinit();
680 594
681 // A client attaches, and NOT because the recording needs one: the test 595 // A client attaches, and NOT because the recording needs one. It is here
682 // thirty lines up proves the first pty chunk builds the tracker with 596 // because `reap` does more for a session that HAS clients — queue
683 // nobody attached, and this test passes with the whole block removed 597 // `exit_status`, drain, drop, then free — so attaching puts the free after
684 // (checked, not assumed). 598 // that sequence rather than after two no-op loops.
685 //
686 // It is here because reapSessions does more for a session that has
687 // clients than for one that does not — it queues exit_status to each of
688 // them, drains, and drops them, and only then frees. Attaching is what
689 // puts the free after that sequence rather than after two no-op loops.
690 // It is also the ordinary case: a session dies while someone is using it.
691 const c = try std.net.connectUnixSocket(sock_path); 599 const c = try std.net.connectUnixSocket(sock_path);
692 defer c.close(); 600 defer c.close();
693 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); 601 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
@@ -700,12 +608,9 @@ test "Server: a session that dies holding a pending event frees it" {
700 // below frees nothing and the leak this pins could not occur. 608 // below frees nothing and the leak this pins could not occur.
701 for (srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null); 609 for (srv.sessions.table[0].?.pendingSlots()) |slot| try std.testing.expect(slot.* != null);
702 610
703 // Now let the shell exit. This is the reapSessions teardown — a session 611 // Now let the shell exit: the `reap` teardown, a session dying while the
704 // dying while the daemon lives on — and it is the site the other gap 612 // daemon lives on. The other gap tests never reach it — they hold their
705 // tests never reach, because every one of them holds its shell open and 613 // shells open and so exercise only `Server.deinit`.
706 // so exercises only Server.deinit. A shell that exits between the copy
707 // and the next attach is the ordinary case: you copy, the command
708 // finishes, the shell goes away.
709 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "die\n"); 614 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "die\n");
710 var reaped = false; 615 var reaped = false;
711 for (0..600) |_| { 616 for (0..600) |_| {
src/server/server_test_harness.zig
Old New
@@ -14,11 +14,9 @@ pub fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void {
14 } 14 }
15 } 15 }
16 16
17 /// Test helper: bring a replica engine up to date with one daemon frame. 17 /// Bring a replica engine up to date with one daemon frame. The replay lives in
18 /// The replay itself lives in replica.zig — the production client's — so 18 /// replica.zig — the production client's — so these tests replay through the
19 /// these tests replay through the same code the client ships, not a 19 /// code the client ships and not a hand-rolled twin.
20 /// hand-rolled twin. The Replica here is per-call scaffolding around the
21 /// borrowed engine; resume state is not what these tests assert.
22 pub fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame) !void { 20 pub fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame) !void {
23 if (frame.type != .snapshot and frame.type != .delta) return; 21 if (frame.type != .snapshot and frame.type != .delta) return;
24 var r = replica_mod.Replica.init(alloc, replica); 22 var r = replica_mod.Replica.init(alloc, replica);
@@ -27,12 +25,10 @@ pub fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame
27 if (try r.apply(frame.type, frame.payload) == .resync) return error.BadPayload; 25 if (try r.apply(frame.type, frame.payload) == .resync) return error.BadPayload;
28 } 26 }
29 27
30 /// Test helper: a connected pair of unix stream sockets, standing in for an 28 /// A connected pair of unix stream sockets, standing in for an attached client.
31 /// attached client. Sockets rather than pipes because the client send path 29 /// SOCKETS rather than pipes because the client send path is `send(2)`, which on
32 /// is send(2), which on a pipe fd fails ENOTSOCK — and std.posix.send maps 30 /// a pipe fd fails ENOTSOCK — and `std.posix.send` maps that errno to
33 /// that errno to `unreachable`, i.e. a panic, not an error. std.posix has no 31 /// `unreachable`. `std.posix` has no socketpair on 0.15.2.
34 /// socketpair on 0.15.2, so a throwaway listener in the test's tmpDir does
35 /// the job. `daemon` goes in a client slot; `peer` is the client's end.
36 pub const SockPair = struct { daemon: std.posix.fd_t, peer: std.posix.fd_t }; 32 pub const SockPair = struct { daemon: std.posix.fd_t, peer: std.posix.fd_t };
37 33
38 pub fn connectedPair(dir_path: []const u8, name: []const u8) !SockPair { 34 pub fn connectedPair(dir_path: []const u8, name: []const u8) !SockPair {
@@ -84,12 +80,8 @@ pub fn firstStateFrame(alloc: std.mem.Allocator, fd: std.posix.fd_t, timeout_ms:
84 } 80 }
85 81
86 // --------------------------------------------------------------------------- 82 // ---------------------------------------------------------------------------
87 // QUIC integration tests. 83 // QUIC integration tests: that a QUIC client still gets the shell's exit code,
88 // 84 // and that one client leaving does not take the others with it.
89 // These two exist because the claims they check — that a QUIC client still
90 // gets the shell's exit code, and that one client leaving does not take the
91 // others with it — were, until they were written, assertions of mine rather
92 // than evidence.
93 // --------------------------------------------------------------------------- 85 // ---------------------------------------------------------------------------
94 86
95 /// Bring a Server up with a QUIC listener bound to an ephemeral loopback 87 /// Bring a Server up with a QUIC listener bound to an ephemeral loopback
@@ -143,12 +135,9 @@ pub fn findFrame(bytes: []const u8, want: proto.MsgType) ?[]const u8 {
143 } 135 }
144 136
145 // --------------------------------------------------------------------------- 137 // ---------------------------------------------------------------------------
146 // endpoint_req: the lazy QUIC bind. 138 // endpoint_req: the lazy QUIC bind. Two tests on purpose — `Listener` keeps a
147 // 139 // process-global "one at a time" latch, so every binding test must give it back
148 // Consolidated into two tests on purpose. `quic_server.Listener` keeps a 140 // and fewer tests is fewer places that can fail to.
149 // process-global "one listener at a time" latch, so every binding test has to
150 // give it back before the next one asks — fewer tests is fewer places that
151 // can fail to.
152 // --------------------------------------------------------------------------- 141 // ---------------------------------------------------------------------------
153 142
154 /// The read after the poll is blocking: safe only because these replies land 143 /// The read after the poll is blocking: safe only because these replies land
@@ -195,13 +184,10 @@ pub fn awaitGridText(
195 } 184 }
196 185
197 // --------------------------------------------------------------------------- 186 // ---------------------------------------------------------------------------
198 // Named sessions: attach-or-create, and every broadcast staying home. 187 // Named sessions: attach-or-create, and every broadcast staying home. The
199 // 188 // default session is created by init in slot 0, and a session only leaves when
200 // The default session ("0") is created by init in slot 0, and a session only 189 // its shell exits. Every boundary assertion has a LIVENESS half first, or a
201 // leaves when its shell exits — the tests that lean on slot-0 placement use 190 // leak test against a silent session passes vacuously.
202 // shells that never do. Every boundary assertion below has a liveness half
203 // first — a leak test against a session that never spoke would pass
204 // vacuously.
205 // --------------------------------------------------------------------------- 191 // ---------------------------------------------------------------------------
206 192
207 /// Attach `fd` to session `name` at the given size, holding nothing — 193 /// Attach `fd` to session `name` at the given size, holding nothing —
@@ -211,13 +197,10 @@ pub fn attachNamed(fd: std.posix.fd_t, cols: u16, rows: u16, name: []const u8) !
211 try proto.writeFrame(fd, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name)); 197 try proto.writeFrame(fd, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name));
212 } 198 }
213 199
214 /// The shell EXITS instead of sleeping: a session dies through `reapSessions`, 200 /// The shell EXITS instead of sleeping, so a session dies through `reap` rather
215 /// not `Server.deinit`, and both honour the teardown half of the pending 201 /// than `Server.deinit` — the tests above reach only deinit. The second `read`
216 /// contract; the tests above reach only deinit. 202 /// makes the death triggerable on its own, since the slots must be seen FULL
217 /// 203 /// first.
218 /// The second `read` makes the death triggerable on its own: the slots must be
219 /// seen FULL before the session dies. Both kinds, because the teardown frees
220 /// the whole set.
221 pub fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 204 pub fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
222 try tmp.dir.writeFile(.{ 205 try tmp.dir.writeFile(.{
223 .sub_path = "gapdie.sh", 206 .sub_path = "gapdie.sh",
@@ -240,13 +223,11 @@ pub fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
240 /// stdin again, so closing the master cannot end it either — SIGKILL is the 223 /// stdin again, so closing the master cannot end it either — SIGKILL is the
241 /// only thing left that can. What a bounded end has to survive. 224 /// only thing left that can. What a bounded end has to survive.
242 pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 225 pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
243 // Blocked in open(2) on a fifo nobody writes: no child to orphan, no 226 // Blocked in `open(2)` on a fifo nobody writes: no child to orphan, no spin.
244 // spin. `sleep` in a loop left a `sleep` behind for up to its full 227 // A `sleep` loop leaves a `sleep` behind past the shell's SIGKILL, and
245 // second (measured: 791 ms past the shell's SIGKILL), and looping on 228 // looping on `read` off the closed master burns a core on EOF. The loop
246 // `read` off the closed master burns a whole core on EOF (measured: 99 229 // stays as the fallback, so a box that cannot mkfifo still gets a stubborn
247 // ticks/s). The loop stays as the fallback so a box that cannot mkfifo 230 // shell rather than one that quietly exits.
248 // gets a shell that is still stubborn — a fixture that quietly exits
249 // here would let the bounded-end gates pass for the wrong reason.
250 const body = try std.fmt.allocPrint(alloc, 231 const body = try std.fmt.allocPrint(alloc,
251 \\#!/bin/sh 232 \\#!/bin/sh
252 \\trap '' TERM HUP 233 \\trap '' TERM HUP
src/server/server_test_modes.zig
Old New
@@ -53,14 +53,10 @@ test "Server: an attach arriving on an established connection is answered with t
53 53
54 const c = try connectedPair(dir_path, "reattach-c"); 54 const c = try connectedPair(dir_path, "reattach-c");
55 defer std.posix.close(c.peer); 55 defer std.posix.close(c.peer);
56 // Already a client, before it has said a word. That is not an odd 56 // Already a client before it has said a word — how a QUIC client starts life,
57 // corner: it is exactly how a QUIC client starts life, since its slot 57 // since its slot is created at handshake and its FIRST attach reaches
58 // is created when the handshake completes, so its FIRST attach reaches 58 // `handleFrame` rather than `serviceObserver`. The two attach paths are
59 // handleFrame rather than serviceObserver. The two attach paths are 59 // separate arms, each needing its own send. No session on purpose.
60 // separate arms and each needs its own send — this test exists because
61 // deleting the one in handleFrame survived the whole suite. No session
62 // on purpose: a promoted QUIC slot starts with none, and the attach
63 // below is what resolves one.
64 srv.clients[0] = .{ .sink = .{ .socket = c.daemon } }; 60 srv.clients[0] = .{ .sink = .{ .socket = c.daemon } };
65 61
66 var modes: std.ArrayList(proto.PtyModeFlags) = .empty; 62 var modes: std.ArrayList(proto.PtyModeFlags) = .empty;
@@ -99,14 +95,10 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
99 const sock_path = try std.fmt.allocPrint(alloc, "{s}/mode.sock", .{dir_path}); 95 const sock_path = try std.fmt.allocPrint(alloc, "{s}/mode.sock", .{dir_path});
100 defer alloc.free(sock_path); 96 defer alloc.free(sock_path);
101 97
102 // A scripted session rather than an interactive shell. An interactive 98 // A SCRIPT, because readline takes the tty out of canonical mode per line
103 // shell is the worst possible subject here: readline takes the tty out 99 // and puts it back, so the bits flap continuously and "changed exactly once"
104 // of canonical mode to read each line and puts it back to run the 100 // could not be asserted. It ends on a blocking read, so `deinit`'s SIGTERM
105 // command, so the bits flap continuously and "changed exactly once" 101 // lands on the shell itself.
106 // could not be asserted against it. This child changes the mode when
107 // told to and at no other time. It ends on a blocking read rather than a
108 // sleep so that deinit's SIGTERM lands on the shell itself and leaves
109 // nothing running behind the test.
110 try tmp.dir.writeFile(.{ 102 try tmp.dir.writeFile(.{
111 .sub_path = "session.sh", 103 .sub_path = "session.sh",
112 .data = 104 .data =
@@ -160,13 +152,9 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
160 // context where it must make none at all. 152 // context where it must make none at all.
161 try std.testing.expect(modes.items[1].icanon); 153 try std.testing.expect(modes.items[1].icanon);
162 154
163 // The second transition, and the one that pins where the poll lives. Echo 155 // The transition that pins where the poll lives: echo is off, so between
164 // is off now, so this keystroke is not echoed and `stty -icanon` prints 156 // here and the frame below the pty emits NOT ONE BYTE. A daemon that checked
165 // nothing either: between here and the frame below the pty emits not one 157 // the mode only after reading pty output sits here forever.
166 // byte. A daemon that checked the mode only after reading pty output
167 // would sit here forever, and did — this arrived as a mutation that the
168 // echo-off phase alone failed to catch, because that phase still had the
169 // echo of its own trigger passing through at about the right moment.
170 try proto.writeFrame(c.handle, .input, "silent\n"); 158 try proto.writeFrame(c.handle, .input, "silent\n");
171 159
172 rounds = 0; 160 rounds = 0;
@@ -177,12 +165,9 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
177 try std.testing.expect(!modes.items[2].icanon); 165 try std.testing.expect(!modes.items[2].icanon);
178 try std.testing.expect(!modes.items[2].echo); 166 try std.testing.expect(!modes.items[2].echo);
179 167
180 // And then nothing. The pty is sitting in a blocking read with its mode 168 // And then nothing: the pty is in a blocking read with its mode unchanged,
181 // unchanged, so a daemon that re-sent what it had already said would 169 // so a daemon that re-sent what it already said shows up as a fourth frame
182 // show up here as a fourth frame — on the very next pump, which is why 170 // on the very NEXT pump. 25 rounds, because round one catches it.
183 // this is 25 rounds and not the 100 it started as. A re-sending daemon
184 // is caught by round one; the rest were paying about two seconds of
185 // every `make test` for evidence already in hand.
186 rounds = 0; 171 rounds = 0;
187 while (rounds < 25) : (rounds += 1) { 172 while (rounds < 25) : (rounds += 1) {
188 try pumpAndCollectModes(alloc, &srv, c.handle, &modes); 173 try pumpAndCollectModes(alloc, &srv, c.handle, &modes);
@@ -198,12 +183,10 @@ test "Server: a BEL from the session reaches its client as a bell term_event" {
198 const sock_path = try std.fmt.allocPrint(alloc, "{s}/bell.sock", .{tmp.path()}); 183 const sock_path = try std.fmt.allocPrint(alloc, "{s}/bell.sock", .{tmp.path()});
199 defer alloc.free(sock_path); 184 defer alloc.free(sock_path);
200 185
201 // A sibling of the clipboard test rather than another leg inside it: that 186 // A sibling of the clipboard test rather than a leg inside it, which is named
202 // one is named for clipboard SCOPING, and a bell assertion bolted on would 187 // for clipboard SCOPING. What is untested is the drain's `.bell` arm:
203 // leave its name describing half of what it pins. What is untested is the 188 // `encodeBellEvent` and the client's rendering are each pinned alone, and
204 // drain's `.bell` arm — a wrong list or a wrong encode there is caught by 189 // neither runs this function.
205 // nothing, since encodeBellEvent and the client's rendering are each
206 // pinned alone and neither runs this function.
207 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 190 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
208 defer srv.deinit(); 191 defer srv.deinit();
209 192
@@ -236,21 +219,13 @@ test "Server: a burst of bells in one chunk is coalesced into one frame" {
236 const sock_path = try std.fmt.allocPrint(alloc, "{s}/bellburst.sock", .{tmp.path()}); 219 const sock_path = try std.fmt.allocPrint(alloc, "{s}/bellburst.sock", .{tmp.path()});
237 defer alloc.free(sock_path); 220 defer alloc.free(sock_path);
238 221
239 // A script rather than `cat`, unlike the test above, and the count is the 222 // A script rather than `cat`, and the COUNT is the reason: typed input is
240 // reason. Typed input is echoed by the tty, so five typed BELs can reach 223 // echoed, so five typed BELs can reach the engine twice in two chunks and
241 // the engine TWICE — once as the echo and once written back — in two 224 // therefore two honest drains. A file writes them once.
242 // chunks and therefore two honest drains. A file writes them once, in one
243 // write, so "one frame" here is a statement about coalescing rather than
244 // about what ECHOCTL happened to do.
245 // 225 //
246 // The `read` is not decoration, and this test failed both ways without it 226 // The `read` is not decoration: a script that rings at once races the attach,
247 // before it was added. A script that rings at once races the attach: the 227 // and the drain then has nobody to queue to — the event is recorded pending,
248 // shell can reach printf before the client is in the session, and then the 228 // but a fresh attach takes the snapshot branch and replays nothing.
249 // drain has nobody to queue to. The event IS recorded pending — but a
250 // fresh attach takes the snapshot branch, which replays nothing (a
251 // reattach quoting a seq is what collects a gap), so the ring is simply
252 // gone and the test fails claiming no bell rather than too many. Ringing
253 // only once released proves the client was there to miss it.
254 try tmp.dir.writeFile(.{ 229 try tmp.dir.writeFile(.{
255 .sub_path = "bellburst.sh", 230 .sub_path = "bellburst.sh",
256 .data = 231 .data =
@@ -307,21 +282,14 @@ test "Server: a bell in a later chunk is its own frame, not folded into the firs
307 const sock_path = try std.fmt.allocPrint(alloc, "{s}/belltwice.sock", .{tmp.path()}); 282 const sock_path = try std.fmt.allocPrint(alloc, "{s}/belltwice.sock", .{tmp.path()});
308 defer alloc.free(sock_path); 283 defer alloc.free(sock_path);
309 284
310 // Coalescing is PER DRAIN, and this is the half of that choice the burst 285 // Coalescing is PER DRAIN, and this is the half the burst test cannot see:
311 // test above cannot see: hoisting the flag to session state would pass it 286 // hoisting the flag to session state would pass it and silence every ring
312 // and silence every ring after the first for the session's whole life. 287 // after the first for the session's life.
313 // 288 //
314 // Each ring is released by its own `read`, which buys two things. It puts 289 // Each ring is released by its own `read`, which puts the two in separate
315 // the two rings in separate chunks — so the second is a drain of its own 290 // chunks and both strictly after the attach. What is typed to release them
316 // rather than a hoped-for split of one write — and it puts BOTH strictly 291 // is `go`, never a BEL: an echoed bell could arrive as a second event and
317 // after the attach, which is the race the burst test above documents: a 292 // pass this test on the leftovers of the first.
318 // shell that rings before the client is in the session has nobody to queue
319 // to, and a fresh attach replays no pending event.
320 //
321 // What gets typed to release them is `go`, never a BEL. With `cat` the
322 // echo of a typed bell could itself arrive as a second event, and this
323 // test would pass on the leftovers of the first ring while the second was
324 // being swallowed — green for precisely the bug it exists to catch.
325 try tmp.dir.writeFile(.{ 293 try tmp.dir.writeFile(.{
326 .sub_path = "belltwice.sh", 294 .sub_path = "belltwice.sh",
327 .data = 295 .data =
@@ -529,13 +497,10 @@ test "Server: a joiner that resizes the grid is still told the session's modes"
529 } else false; 497 } else false;
530 try std.testing.expect(enabled); 498 try std.testing.expect(enabled);
531 499
532 // The third arm of sendResync, and the one the other two tests cannot 500 // The third arm of `sendResync`, which the other two tests cannot reach: a
533 // reach: a joiner at a DIFFERENT size returns early through 501 // joiner at a DIFFERENT size returns early through `resyncSnapshot`. Left
534 // resyncSnapshot, before the delta/snapshot split either of them takes. 502 // bare, bracketed paste stops being mirrored after any window resize, and
535 // Left bare it is a real regression and a quiet one — bracketed paste 503 // nothing connects a shell echoing a literal `200~` to the resize.
536 // would stop being mirrored after any window resize, and nothing the
537 // user sees connects a shell echoing a literal `200~` back to the
538 // resize that caused it.
539 const b = try std.net.connectUnixSocket(sock_path); 504 const b = try std.net.connectUnixSocket(sock_path);
540 defer b.close(); 505 defer b.close();
541 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(100, 30, 0, 0)); 506 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(100, 30, 0, 0));
@@ -581,12 +546,9 @@ test "Server: a window title reaches clients on change, and only on change" {
581 546
582 try tmp.dir.writeFile(.{ 547 try tmp.dir.writeFile(.{
583 .sub_path = "title.sh", 548 .sub_path = "title.sh",
584 // The leading `read` is what makes this test about the SAMPLER. A 549 // The leading `read` is what makes this about the SAMPLER: a shell that
585 // shell that printed its title immediately would race the attach: 550 // printed its title at once would race the attach, and the frame this
586 // the title would already exist when sendResync ran, and the frame 551 // waits for could be the resync's.
587 // this test waits for could be the resync's. Gating the printf on
588 // input the daemon can only forward to an attached client puts the
589 // client there first, so nothing but the sampler can send it.
590 .data = 552 .data =
591 \\#!/bin/sh 553 \\#!/bin/sh
592 \\read -r go 554 \\read -r go
@@ -678,10 +640,8 @@ test "Server: a session that never set a title has none sent for it" {
678 defer srv.deinit(); 640 defer srv.deinit();
679 641
680 // Two attaches, because the two paths that could send an empty title are 642 // Two attaches, because the two paths that could send an empty title are
681 // different code: the sampler (driven by the pty output below) and the 643 // different code: the SAMPLER and the RESYNC. An empty title on either makes
682 // resync (driven by the second attach). An empty title on either would 644 // the client wipe the title bar of a terminal whose session said nothing.
683 // make the client write ESC]0;BEL and wipe the title bar of a terminal
684 // whose session has said nothing whatsoever about titles.
685 const a = try std.net.connectUnixSocket(sock_path); 645 const a = try std.net.connectUnixSocket(sock_path);
686 defer a.close(); 646 defer a.close();
687 try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); 647 try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
@@ -837,11 +797,9 @@ test "Server: a resize reaches the pty as an in-band size report when the app as
837 defer tmp.cleanup(); 797 defer tmp.cleanup();
838 const sock_path = try std.fmt.allocPrint(alloc, "{s}/inband.sock", .{tmp.path()}); 798 const sock_path = try std.fmt.allocPrint(alloc, "{s}/inband.sock", .{tmp.path()});
839 defer alloc.free(sock_path); 799 defer alloc.free(sock_path);
840 // `cat`, not a shell: the oracle below is the line discipline's own echo, 800 // `cat`, not a shell: the oracle below is the line discipline's own echo, and
841 // and a shell starts a line editor that turns ECHO off and reads the 801 // a shell's line editor turns ECHO off and reads the report as editing keys —
842 // report as editing keys instead — `ESC [` then digits is a numeric 802 // `ESC [` then digits is a numeric argument to readline.
843 // argument to readline. Whether the write lands before that happens is a
844 // race the test won about 14 times in 15. `cat` never touches termios.
845 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 803 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
846 defer srv.deinit(); 804 defer srv.deinit();
847 805
src/server/server_test_session.zig
Old New
@@ -119,12 +119,9 @@ test "Server: a path that cannot be bound fails as AddressInUse" {
119 const sock_path = try std.fmt.allocPrint(alloc, "{s}/dangling.sock", .{dir_path}); 119 const sock_path = try std.fmt.allocPrint(alloc, "{s}/dangling.sock", .{dir_path});
120 defer alloc.free(sock_path); 120 defer alloc.free(sock_path);
121 121
122 // A dangling symlink reaches bind() the way a lost start-up race does, 122 // A dangling symlink reaches `bind()` the way a lost start-up race does, but
123 // but deterministically: connect through it gets ENOENT (so the probe 123 // deterministically: the connect gets ENOENT so the probe reads the path as
124 // reads the path as free, exactly as the loser of a race does), while 124 // free, while the bind gets EADDRINUSE off the symlink's own entry.
125 // bind() gets EADDRINUSE off the directory entry the symlink itself
126 // occupies. Racing two real daemons would test the same prong by
127 // coin-flip; this pins it every run.
128 try tmp.dir.symLink("no-such-target", "dangling.sock", .{}); 125 try tmp.dir.symLink("no-such-target", "dangling.sock", .{});
129 126
130 try expectInitRefused(alloc, sock_path, error.AddressInUse); 127 try expectInitRefused(alloc, sock_path, error.AddressInUse);
@@ -162,24 +159,15 @@ test "Server: a non-socket at the path is refused, not deleted" {
162 try std.testing.expectEqualStrings(contents, after); 159 try std.testing.expectEqualStrings(contents, after);
163 } 160 }
164 161
165 // Placed BEFORE the QUIC integration tests below, and that is not 162 // BEFORE the QUIC integration tests, and not cosmetically: widening `deinit`'s
166 // cosmetic. Widening deinit's `.owned` arm to free a `.borrowed` listener 163 // `.owned` arm to free a `.borrowed` listener makes the daemon free one it does
167 // too makes the daemon free one it does not own; every test down there hands 164 // not own, and every test down there hands one in and frees it itself — so the
168 // one in and frees it itself, so under that regression they double-free and 165 // regression double-frees and WEDGES in wolfSSL teardown, printing nothing at
169 // the binary wedges in wolfSSL teardown. A wedged step prints nothing at 166 // all. Here it fails first and says what broke.
170 // all — the build runner has no completed step to report — so a pin sitting
171 // after them would be swallowed by the very failure it exists to name. Here,
172 // it fails first and says what broke.
173 // 167 //
174 // With this pin present the run fails here first; remove the pin and the 168 // Worth knowing before moving it: a FAILURE here returns before its own
175 // same regression reaches the section below as a double free. There is a 169 // `l.deinit()`, so the process-global latch stays held and every QUIC test
176 // second effect worth knowing before this test is ever moved or deleted: a 170 // below refuses its bind rather than reaching the free.
177 // FAILURE here returns before its own `l.deinit()`, so g_listener_live stays
178 // held and every QUIC test below refuses its bind instead of reaching the
179 // free at all. Seven failures below, observed — six of them
180 // error.ListenerAlreadyRunning outright, the seventh endpoint_req's port
181 // coming back 0 with the same refusal printed — where there used to be a
182 // hang.
183 test "Server: a listener attached by the caller survives srv.deinit — ownership stays with whoever bound it" { 171 test "Server: a listener attached by the caller survives srv.deinit — ownership stays with whoever bound it" {
184 const alloc = std.testing.allocator; 172 const alloc = std.testing.allocator;
185 173
@@ -193,14 +181,10 @@ test "Server: a listener attached by the caller survives srv.deinit — ownershi
193 var sbuf: [128]u8 = undefined; 181 var sbuf: [128]u8 = undefined;
194 const sock_path = try std.fmt.bufPrint(&sbuf, "{s}/ep3.sock", .{tmp.path()}); 182 const sock_path = try std.fmt.bufPrint(&sbuf, "{s}/ep3.sock", .{tmp.path()});
195 183
196 // Bound out here, exactly as main.zig's explicit --quic path binds it 184 // Bound out here, as main.zig's explicit `--quic` path does. Deliberately NOT
197 // before the Server exists. Deliberately NOT deferred: which side frees 185 // deferred: which side frees this IS the claim, so the frees are written in
198 // this is the whole claim, so the frees are written out in the order the 186 // the order the claim is about — a deferred free would be a double free the
199 // claim is about. An assertion that fails before the free below leaks 187 // moment the guard stopped guarding.
200 // the listener and the testing allocator says so — which is noisy, but
201 // a deferred free here would be a double free the moment the guard
202 // under test stopped guarding, and a use-after-free is not a test
203 // result anyone can read.
204 const addr = try std.net.Address.parseIp("0.0.0.0", 0); 188 const addr = try std.net.Address.parseIp("0.0.0.0", 0);
205 const l = try quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms); 189 const l = try quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms);
206 const port = boundUdpPort(l); 190 const port = boundUdpPort(l);
@@ -224,11 +208,9 @@ test "Server: a listener attached by the caller survives srv.deinit — ownershi
224 try std.testing.expectEqual(port, srv.endpointPortFrom(null, null, null)); 208 try std.testing.expectEqual(port, srv.endpointPortFrom(null, null, null));
225 } 209 }
226 210
227 // srv.deinit() has now run. The listener must have survived it: the 211 // `srv.deinit()` has run and the listener must have survived it: the
228 // process-global latch is still held, so a fresh bind is refused. This 212 // process-global latch is still held, so a fresh bind is refused. Without
229 // is the named assertion the ownership guard is pinned by — without it, 213 // this, a deinit that freed somebody else's listener shows up only as a hang.
230 // a deinit that freed somebody else's listener showed up only as a
231 // double-free hang in unrelated tests.
232 try std.testing.expectError( 214 try std.testing.expectError(
233 error.ListenerAlreadyRunning, 215 error.ListenerAlreadyRunning,
234 quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms), 216 quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms),
@@ -252,12 +234,9 @@ test "Server: a clean exit takes its socket file with it" {
252 try std.fs.cwd().access(sock_path, .{}); 234 try std.fs.cwd().access(sock_path, .{});
253 srv.deinit(); 235 srv.deinit();
254 236
255 // The effect, not the reasoning. The guard this replaced compared a 237 // The EFFECT, not the reasoning: a guard comparing a sockfs inode against a
256 // sockfs inode against a filesystem inode, so it was false every time 238 // filesystem one is false every time, and the leftover socket is
257 // and the file survived every clean exit — and nothing noticed, because 239 // indistinguishable from a correct refusal to delete someone else's.
258 // a leftover socket is indistinguishable from a correct refusal to
259 // delete someone else's. Only asking whether the file is GONE can tell
260 // those apart.
261 try std.testing.expectError( 240 try std.testing.expectError(
262 error.FileNotFound, 241 error.FileNotFound,
263 std.fs.cwd().access(sock_path, .{}), 242 std.fs.cwd().access(sock_path, .{}),
@@ -332,12 +311,9 @@ test "Server: stats reports live client slots, and the number comes down again"
332 srv.clients[3] = null; 311 srv.clients[3] = null;
333 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null); 312 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null);
334 313
335 // ...and `attaches=` is the counter beside it, which is the whole 314 // ...and `attaches=` is the COUNTER beside it, which is why both exist:
336 // reason both exist. Occupancy went 0 → 2 → 0 above WITHOUT an attach 315 // occupancy went 0 → 2 → 0 above without an attach frame ever arriving, so
337 // frame ever arriving, so the counter is still 0 here: what it answers 316 // this is still 0. It answers "did anyone attach since I last looked".
338 // is "did anyone attach since I last looked", which no gauge can, and
339 // that is what the CLI wall's zoom is proved against. It only ever
340 // goes up.
341 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=0") != null); 317 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=0") != null);
342 srv.stats.attaches += 1; 318 srv.stats.attaches += 1;
343 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=1") != null); 319 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=1") != null);
@@ -360,12 +336,10 @@ test "Server: stats reports live client slots, and the number comes down again"
360 try std.testing.expect(std.mem.indexOf(u8, text, " session 0 clients=0 seq=0") != null); 336 try std.testing.expect(std.mem.indexOf(u8, text, " session 0 clients=0 seq=0") != null);
361 } 337 }
362 338
363 // A hand-counted bound is the failure mode this pins: `statsText` writes into 339 // A hand-counted bound is the failure this pins: `statsText` writes into a fixed
364 // a fixed buffer, so an under-count is `error.WriteFailed`, and the 340 // buffer, so an under-count is `error.WriteFailed` and the `.stats_req` arm
365 // `.stats_req` arm answers that by dropping the client — `mux d stats` reports 341 // drops the client — `mux d stats` reports the daemon gone rather than a short
366 // the daemon gone rather than a short line. Nothing reaches these values 342 // line. A new counter is what makes these values reachable.
367 // today; a new counter in `stats_main_fmt` is what makes it reachable, and
368 // that is exactly when this test has to be the thing that notices.
369 test "Server: the stats buffer holds the widest reply its format can print" { 343 test "Server: the stats buffer holds the widest reply its format can print" {
370 var buf: [Server.stats_text_len]u8 = undefined; 344 var buf: [Server.stats_text_len]u8 = undefined;
371 var w: std.Io.Writer = .fixed(&buf); 345 var w: std.Io.Writer = .fixed(&buf);
@@ -409,14 +383,10 @@ test "Server: stop_req from a bare connection requests shutdown; run returns 0"
409 } 383 }
410 try std.testing.expect(shutdown_flag.load(.acquire)); 384 try std.testing.expect(shutdown_flag.load(.acquire));
411 385
412 // run() re-checks the flag before its first poll, so this returns 386 // `run()` re-checks the flag before its first poll, so this returns without
413 // without pumping at all — the still-open connection above is deinit's 387 // pumping. It is the one unbounded call here, safe only because the expect
414 // business, not run()'s. It is also the one unbounded call here, and it 388 // above guarantees the flag. 0: a daemon asked to stop did its job, and a
415 // is safe only because the expect above guarantees the flag: an arm 389 // supervisor reads nonzero as a crash-loop.
416 // mutation fails there and never reaches this line.
417 //
418 // 0: a daemon that served and was asked to stop did its job. A nonzero
419 // exit here is what a supervisor reads as a crash-loop.
420 try std.testing.expectEqual(@as(u8, 0), try srv.run()); 390 try std.testing.expectEqual(@as(u8, 0), try srv.run());
421 } 391 }
422 392
@@ -437,12 +407,10 @@ test "Server: stop_req from an attached client is honored too" {
437 const c = try std.net.connectUnixSocket(sock_path); 407 const c = try std.net.connectUnixSocket(sock_path);
438 defer c.close(); 408 defer c.close();
439 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); 409 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
440 // Both frames go out before any pump, and the stop still lands in 410 // Both frames go out before any pump and the stop still lands in
441 // handleFrame rather than the observer switch: serviceObserver's one 411 // `handleFrame`: one observer read takes both into the buffer, the attach
442 // read takes both into the observer's buffer, and the attach promotes 412 // promotes the connection with that buffer, and the stop drains as the
443 // this connection with that buffer moving to the client slot, so the 413 // client's through `pushInbound`.
444 // stop behind it drains as the client's, through pushInbound. Pump
445 // count does not enter into it.
446 try proto.writeFrame(c.handle, .stop_req, ""); 414 try proto.writeFrame(c.handle, .stop_req, "");
447 415
448 var i: usize = 0; 416 var i: usize = 0;
@@ -807,14 +775,11 @@ test "Server: an attach past max_sessions is refused with exit_status, sessions
807 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 775 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
808 defer srv.deinit(); 776 defer srv.deinit();
809 777
810 // The default session holds slot 0 from init, so max_sessions-1 more 778 // The default session holds slot 0, so `max_sessions - 1` more fill the
811 // names fill the table. Each attach is confirmed by its own snapshot 779 // table. Each attach is confirmed before the next, so the refusal below is
812 // before the next, so the refusal below is unambiguously "no session 780 // unambiguously "no session slot" — and each connection CLOSES, because
813 // slot", not a race — and each connection is closed once confirmed 781 // `max_clients` is smaller and would fill first, refusing for the wrong
814 // because max_clients (8) is smaller than max_sessions: holding them 782 // reason. The session outlives its client, which makes that safe.
815 // all open would fill the CLIENT table first and refuse for the wrong
816 // reason. The session outlives its client, which is what makes that
817 // safe.
818 for (1..max_sessions) |i| { 783 for (1..max_sessions) |i| {
819 var nb: [8]u8 = undefined; 784 var nb: [8]u8 = undefined;
820 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i}); 785 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
@@ -847,11 +812,9 @@ test "Server: an attach past max_sessions is refused with exit_status, sessions
847 } 812 }
848 } 813 }
849 814
850 // Plural on purpose: the grace is a per-child number, and a fixture with one 815 // PLURAL on purpose: the grace is a per-child number, and one session cannot
851 // session cannot tell "one grace for the table" from "one grace each". A 816 // tell "one grace for the table" from "one grace each". A supervisor's stop
852 // supervisor with a stop timeout kills a daemon that spends the second shape, 817 // timeout kills a daemon that spends the second shape.
853 // and a killed daemon leaves its socket and its agent sockets unlinked for
854 // the next `mux d start` to reason about.
855 test "Server: a table of TERM-ignoring shells costs one grace, not one each" { 818 test "Server: a table of TERM-ignoring shells costs one grace, not one each" {
856 const alloc = std.testing.allocator; 819 const alloc = std.testing.allocator;
857 820
@@ -1094,14 +1057,10 @@ fn awaitFrameThreaded(
1094 return null; 1057 return null;
1095 } 1058 }
1096 1059
1097 /// Ask the daemon for its stats until `want` appears in the text — what 1060 /// Ask the daemon for its stats until `want` appears. A connection of its OWN
1098 /// `mux d stats` shows an operator looking at a box with nothing on it. 1061 /// per call, which is the assertion's teeth: a reply that misses its 200 ms
1099 /// 1062 /// stays buffered, so a shared socket lets a later question be answered by an
1100 /// A connection of its OWN, per call, and that is the assertion's teeth: a 1063 /// earlier one's frame — generated before the very event being waited on.
1101 /// reply that misses its 200 ms stays buffered, so a shared socket would let
1102 /// a later question be answered by an earlier question's frame — one
1103 /// generated before the very event being waited on, which is how
1104 /// `sessions=1` after a rebirth passes without the rebirth.
1105 fn waitStats(alloc: std.mem.Allocator, sock_path: []const u8, want: []const u8) !void { 1064 fn waitStats(alloc: std.mem.Allocator, sock_path: []const u8, want: []const u8) !void {
1106 const obs = try std.net.connectUnixSocket(sock_path); 1065 const obs = try std.net.connectUnixSocket(sock_path);
1107 defer obs.close(); 1066 defer obs.close();
@@ -1220,11 +1179,9 @@ test "Server: a dead name re-attaches as a fresh session with a new epoch" {
1220 } 1179 }
1221 try std.testing.expect(srv.sessions.find("a") == null); 1180 try std.testing.expect(srv.sessions.find("a") == null);
1222 1181
1223 // Re-attach the dead name at a real size, quoting the DEAD instance's 1182 // Re-attach the dead name quoting the DEAD instance's seq and epoch — the
1224 // seq and epoch — the reconnect a client that missed the death sends. 1183 // reconnect a client that missed the death sends. A fresh session cannot
1225 // A fresh session cannot delta-serve seqs counted by a shell it never 1184 // delta-serve seqs counted by a shell it never was.
1226 // was, so the first state frame must be a snapshot (decision 7), and
1227 // its epoch must be a new instance's.
1228 const c2 = try std.net.connectUnixSocket(sock_path); 1185 const c2 = try std.net.connectUnixSocket(sock_path);
1229 defer c2.close(); 1186 defer c2.close();
1230 var abuf: [proto.attach_max_len]u8 = undefined; 1187 var abuf: [proto.attach_max_len]u8 = undefined;
@@ -1257,9 +1214,8 @@ test "Server: a dead name re-attaches as a fresh session with a new epoch" {
1257 } 1214 }
1258 1215
1259 // --------------------------------------------------------------------------- 1216 // ---------------------------------------------------------------------------
1260 // Every out-of-band instrument learns a session name — dump, the 1217 // Every out-of-band instrument learns a session name: dump, the observer's
1261 // observer's status_req, stats, and the attached-client tail-match rule for 1218 // `status_req`, stats, and the attached-client tail-match rule.
1262 // status_req/await_req.
1263 // --------------------------------------------------------------------------- 1219 // ---------------------------------------------------------------------------
1264 1220
1265 test "Server: dump names a session; an unknown name answers in words" { 1221 test "Server: dump names a session; an unknown name answers in words" {
@@ -1373,11 +1329,9 @@ test "Server: an observer's status_req names a session by tail" {
1373 try std.testing.expectEqual(proto.CmdPhase.running, st.cmd.phase); 1329 try std.testing.expectEqual(proto.CmdPhase.running, st.cmd.phase);
1374 try std.testing.expectEqual(proto.Mechanism.marks, st.cmd.mechanism); 1330 try std.testing.expectEqual(proto.Mechanism.marks, st.cmd.mechanism);
1375 1331
1376 // An unknown name still ends the connection — status_reply is a fixed 1332 // An unknown name still ends the connection, since `status_reply` has no room
1377 // binary layout with no room for words — but it says exit_status 1 1333 // for words — but it says `exit_status` 1 FIRST. Silence made
1378 // first, the same no a refused attach gets. Silence made `mux a status 1334 // `mux a status --session nosuch` report a dead daemon.
1379 // --session nosuch` report `DaemonGone`, which reads as a dead daemon
1380 // rather than a name that was never there.
1381 try proto.writeFrame(obs.handle, .status_req, "z"); 1335 try proto.writeFrame(obs.handle, .status_req, "z");
1382 const refusal = (try awaitFrame(alloc, &srv, obs.handle, .exit_status, 400)) orelse 1336 const refusal = (try awaitFrame(alloc, &srv, obs.handle, .exit_status, 400)) orelse
1383 return error.NoRefusalForUnknownSession; 1337 return error.NoRefusalForUnknownSession;
@@ -1526,16 +1480,11 @@ test "Server: a session-less slot's status_req resolves the tail like an observe
1526 return error.NoSnapshotB; 1480 return error.NoSnapshotB;
1527 fb.deinit(alloc); 1481 fb.deinit(alloc);
1528 1482
1529 // The shape a QUIC connection has between handshake and its first 1483 // The shape a QUIC connection has between handshake and its first attach: a
1530 // attach: a live client slot with no session, reached through 1484 // live client slot with NO session, reached through `pushInbound` exactly as
1531 // pushInbound/handleFrame exactly as a QUIC datagram would be — not 1485 // a datagram would be. A `status_req` arm that `orelse return`s such a slot
1532 // serviceObserver, which only a unix connection can ever land in. This 1486 // makes `mux a status --quic` time out. Slot 1, because slot 0 is claimed
1533 // is the gap the review found: handleFrame's status_req arm used to 1487 // and stomping it would leak its queued snapshot.
1534 // `orelse return` a session-less slot outright, so `mux a status --quic`
1535 // against a live daemon just timed out.
1536 // Slot 1, not 0: cb's own attach already claimed slot 0 (freeClientSlot
1537 // hands out the first free index), and stomping it would leak its
1538 // queued snapshot rather than exercise anything about this arm.
1539 const c = try connectedPair(dir_path, "quicstatus-c"); 1488 const c = try connectedPair(dir_path, "quicstatus-c");
1540 defer std.posix.close(c.peer); 1489 defer std.posix.close(c.peer);
1541 srv.clients[1] = .{ .sink = .{ .socket = c.daemon } }; 1490 srv.clients[1] = .{ .sink = .{ .socket = c.daemon } };
@@ -1564,17 +1513,11 @@ test "Server: a session-less slot's status_req resolves the tail like an observe
1564 try std.testing.expect(srv.clients[1] != null); 1513 try std.testing.expect(srv.clients[1] != null);
1565 try std.testing.expect(srv.clients[1].?.session == null); 1514 try std.testing.expect(srv.clients[1].?.session == null);
1566 1515
1567 // An unknown name refuses in the daemon's one word for no — 1516 // An unknown name refuses with `exit_status` 1 and then drops the CLIENT. The
1568 // exit_status 1 — and then drops the CLIENT, dropClient being the 1517 // frame has to PRECEDE the close, or `mux a status --quic --session nosuch`
1569 // QUIC-shaped analogue of dropObserver's drop. The frame has to 1518 // reads silence as a dead daemon. The SOCKET sink only, which flushes as it
1570 // precede the close: `mux a status --quic --session nosuch` reads 1519 // queues; a quic sink reaches the peer through `reapClosing`'s last drain,
1571 // silence as a dead daemon. 1520 // which a unit test cannot stand up.
1572 //
1573 // The SOCKET sink only, which is what this pair is: it flushes as it
1574 // queues, so the byte is out before the close. A quic sink reaches
1575 // the peer by the other road — queue, `.closing_quiet`, reapClosing's
1576 // last drain — and a unit test cannot stand one up. e2e and the WAN
1577 // box cover that half.
1578 frame.clearRetainingCapacity(); 1521 frame.clearRetainingCapacity();
1579 try proto.appendFrame(&frame, alloc, .status_req, "zz"); 1522 try proto.appendFrame(&frame, alloc, .status_req, "zz");
1580 srv.pushInbound(1, frame.items); 1523 srv.pushInbound(1, frame.items);
@@ -1591,9 +1534,9 @@ test "Server: a session-less slot's status_req resolves the tail like an observe
1591 } 1534 }
1592 1535
1593 // --------------------------------------------------------------------------- 1536 // ---------------------------------------------------------------------------
1594 // Ending a session on request. Each test builds only the clients and 1537 // Ending a session on request. Each test builds only the clients and sessions
1595 // sessions its own claim needs: the count in a refusal is per session, and 1538 // its own claim needs: the count in a refusal is per session, and a client is
1596 // a client is not its own "other", so the refusal test is the plural one. 1539 // not its own "other".
1597 // --------------------------------------------------------------------------- 1540 // ---------------------------------------------------------------------------
1598 1541
1599 fn shellPidOf(srv: *Server, name: []const u8) std.posix.pid_t { 1542 fn shellPidOf(srv: *Server, name: []const u8) std.posix.pid_t {