afd3bd11
refactor: the daemon's tests name what they pin, in two lines
a73x 2026-08-30 20:18
Commit message
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 { |