b96a01d5
test: rawmode and delaypipe — deterministic mode transitions and root-free RTT
a73x 2026-08-08 15:52
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -168,6 +168,22 @@ pub fn build(b: *std.Build) void { | |||
| 168 | }); | 168 | }); |
| 169 | proxy_mod.addImport("testtmp", testtmp_mod); | 169 | proxy_mod.addImport("testtmp", testtmp_mod); |
| 170 | 170 | ||
| 171 | // Test helpers, built as real binaries because that is how the suite | ||
| 172 | // uses them: rawmode is a deterministic stand-in for an editor (nvim's | ||
| 173 | // redraw timing is its own business and it is not installed everywhere), | ||
| 174 | // and delaypipe makes a slow round trip out of a shell pipeline instead | ||
| 175 | // of out of netem and root. | ||
| 176 | const rawmode_mod = b.createModule(.{ | ||
| 177 | .root_source_file = b.path("test/rawmode.zig"), | ||
| 178 | .target = target, | ||
| 179 | .optimize = optimize, | ||
| 180 | }); | ||
| 181 | const delaypipe_mod = b.createModule(.{ | ||
| 182 | .root_source_file = b.path("test/delaypipe.zig"), | ||
| 183 | .target = target, | ||
| 184 | .optimize = optimize, | ||
| 185 | }); | ||
| 186 | |||
| 171 | const exe_mod = b.createModule(.{ | 187 | const exe_mod = b.createModule(.{ |
| 172 | .root_source_file = b.path("src/main.zig"), | 188 | .root_source_file = b.path("src/main.zig"), |
| 173 | .target = target, | 189 | .target = target, |
| @@ -198,13 +214,23 @@ pub fn build(b: *std.Build) void { | |||
| 198 | linkQuic(b, mux_exe, quic); | 214 | linkQuic(b, mux_exe, quic); |
| 199 | b.installArtifact(mux_exe); | 215 | b.installArtifact(mux_exe); |
| 200 | 216 | ||
| 217 | const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); | ||
| 218 | rawmode_exe.use_llvm = true; | ||
| 219 | rawmode_exe.use_lld = true; | ||
| 220 | b.installArtifact(rawmode_exe); | ||
| 221 | |||
| 222 | const delaypipe_exe = b.addExecutable(.{ .name = "delaypipe", .root_module = delaypipe_mod }); | ||
| 223 | delaypipe_exe.use_llvm = true; | ||
| 224 | delaypipe_exe.use_lld = true; | ||
| 225 | b.installArtifact(delaypipe_exe); | ||
| 226 | |||
| 201 | const test_step = b.step("test", "Run unit tests"); | 227 | const test_step = b.step("test", "Run unit tests"); |
| 202 | // mux_mod and exe_mod are executable roots, but they carry the argument | 228 | // mux_mod and exe_mod are executable roots, but they carry the argument |
| 203 | // parsers, and a test that is never built is not a test. exe_mod's | 229 | // parsers, and a test that is never built is not a test. exe_mod's |
| 204 | // absence here was a live hazard recorded in decisions.md — muxd's | 230 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 205 | // entrypoint could grow tests that silently never ran, exactly as | 231 | // entrypoint could grow tests that silently never ran, exactly as |
| 206 | // mux_main.zig's five did before it was added. | 232 | // mux_main.zig's five did before it was added. |
| 207 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod }) |mod| { | 233 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod }) |mod| { |
| 208 | const t = b.addTest(.{ .root_module = mod }); | 234 | const t = b.addTest(.{ .root_module = mod }); |
| 209 | t.use_llvm = true; | 235 | t.use_llvm = true; |
| 210 | t.use_lld = true; | 236 | t.use_lld = true; |
test/delaypipe.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,132 @@ | |||
| 1 | //! A byte pump that adds a fixed one-way delay, so a round trip can be made | ||
| 2 | //! slow without root. | ||
| 3 | //! | ||
| 4 | //! Every latency measurement so far has needed `tc netem` on a real box, | ||
| 5 | //! which means a machine, an interface, and sudo. Prediction's whole claim | ||
| 6 | //! is that echo latency stops depending on the round trip, and demonstrating | ||
| 7 | //! that needs a round trip long enough to see — but not a real one. | ||
| 8 | //! | ||
| 9 | //! `--via` runs its argument through `/bin/sh -c`, so two of these compose | ||
| 10 | //! into a symmetric delay with a shell pipeline and nothing else: | ||
| 11 | //! | ||
| 12 | //! mux --via "delaypipe | muxd proxy --sock S | delaypipe" | ||
| 13 | //! | ||
| 14 | //! The client's bytes go through the first before reaching the daemon, the | ||
| 15 | //! daemon's answers through the second on the way back: DELAY_MS each way, | ||
| 16 | //! 2*DELAY_MS round trip, no privileges anywhere. | ||
| 17 | //! | ||
| 18 | //! The delay is applied per chunk and serially — a chunk is read, held, and | ||
| 19 | //! written before the next read — so throughput is capped at one chunk per | ||
| 20 | //! DELAY_MS. That is fine for what this is for (a person typing, and a shell | ||
| 21 | //! answering) and would be wrong for a throughput benchmark. Said plainly | ||
| 22 | //! because the cap is invisible until something measures against it: a burst | ||
| 23 | //! arriving while a chunk is being held is coalesced into the next one, so | ||
| 24 | //! bulk output is delayed once rather than once per byte. | ||
| 25 | const std = @import("std"); | ||
| 26 | |||
| 27 | pub const default_delay_ms: u64 = 150; | ||
| 28 | |||
| 29 | /// Pump `in_fd` to `out_fd`, holding every chunk for `delay_ms` first. | ||
| 30 | /// Returns at EOF or on any error. Closes neither descriptor. | ||
| 31 | pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, delay_ms: u64) void { | ||
| 32 | var buf: [64 * 1024]u8 = undefined; | ||
| 33 | while (true) { | ||
| 34 | const n = std.posix.read(in_fd, &buf) catch return; | ||
| 35 | if (n == 0) return; | ||
| 36 | // Held after arrival and before delivery, which is what makes the | ||
| 37 | // delay a property of the path rather than of the sender. | ||
| 38 | std.Thread.sleep(delay_ms * std.time.ns_per_ms); | ||
| 39 | writeAll(out_fd, buf[0..n]) catch return; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | fn writeAll(fd: std.posix.fd_t, data: []const u8) !void { | ||
| 44 | var i: usize = 0; | ||
| 45 | while (i < data.len) i += try std.posix.write(fd, data[i..]); | ||
| 46 | } | ||
| 47 | |||
| 48 | /// `DELAY_MS` from the environment, or the default. An unparseable value is | ||
| 49 | /// the default too: this is a test helper, and failing to start would look | ||
| 50 | /// like a broken transport rather than a typo. | ||
| 51 | pub fn delayFromEnv() u64 { | ||
| 52 | const raw = std.posix.getenv("DELAY_MS") orelse return default_delay_ms; | ||
| 53 | return std.fmt.parseInt(u64, raw, 10) catch default_delay_ms; | ||
| 54 | } | ||
| 55 | |||
| 56 | pub fn main() void { | ||
| 57 | pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, delayFromEnv()); | ||
| 58 | } | ||
| 59 | |||
| 60 | const Harness = struct { | ||
| 61 | to: [2]std.posix.fd_t, | ||
| 62 | from: [2]std.posix.fd_t, | ||
| 63 | thread: std.Thread, | ||
| 64 | |||
| 65 | fn start(delay_ms: u64) !Harness { | ||
| 66 | const to = try std.posix.pipe(); | ||
| 67 | const from = try std.posix.pipe(); | ||
| 68 | return .{ | ||
| 69 | .to = to, | ||
| 70 | .from = from, | ||
| 71 | .thread = try std.Thread.spawn(.{}, pump, .{ to[0], from[1], delay_ms }), | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | fn finish(self: *Harness) void { | ||
| 76 | std.posix.close(self.to[1]); // EOF ends the pump | ||
| 77 | self.thread.join(); | ||
| 78 | std.posix.close(self.to[0]); | ||
| 79 | std.posix.close(self.from[0]); | ||
| 80 | std.posix.close(self.from[1]); | ||
| 81 | } | ||
| 82 | }; | ||
| 83 | |||
| 84 | test "a byte arrives no earlier than the delay, and arrives intact" { | ||
| 85 | var h = try Harness.start(120); | ||
| 86 | defer h.finish(); | ||
| 87 | |||
| 88 | const start = std.time.milliTimestamp(); | ||
| 89 | try writeAll(h.to[1], "x"); | ||
| 90 | |||
| 91 | var buf: [16]u8 = undefined; | ||
| 92 | const n = try std.posix.read(h.from[0], &buf); | ||
| 93 | const elapsed = std.time.milliTimestamp() - start; | ||
| 94 | |||
| 95 | try std.testing.expectEqualStrings("x", buf[0..n]); | ||
| 96 | // The lower bound is the whole point, and it is asserted with a little | ||
| 97 | // room for clock granularity rather than at exactly 120: sleep promises | ||
| 98 | // at least its duration, and a millisecond of rounding either way must | ||
| 99 | // not make this flaky. A pump that forgot to wait lands near zero and | ||
| 100 | // fails this by a hundred milliseconds, not by one. | ||
| 101 | try std.testing.expect(elapsed >= 100); | ||
| 102 | } | ||
| 103 | |||
| 104 | test "a payload larger than one write survives the crossing in order" { | ||
| 105 | var h = try Harness.start(5); | ||
| 106 | defer h.finish(); | ||
| 107 | |||
| 108 | // Written in pieces, so the pump sees several chunks and each one is | ||
| 109 | // delayed independently. Order is the property under test: a pump that | ||
| 110 | // held chunks concurrently could deliver them out of sequence, and a | ||
| 111 | // transport that reorders bytes is not a transport. | ||
| 112 | try writeAll(h.to[1], "alpha-"); | ||
| 113 | try writeAll(h.to[1], "beta-"); | ||
| 114 | try writeAll(h.to[1], "gamma"); | ||
| 115 | |||
| 116 | const alloc = std.testing.allocator; | ||
| 117 | var got: std.ArrayList(u8) = .empty; | ||
| 118 | defer got.deinit(alloc); | ||
| 119 | var buf: [64]u8 = undefined; | ||
| 120 | while (got.items.len < "alpha-beta-gamma".len) { | ||
| 121 | const n = try std.posix.read(h.from[0], &buf); | ||
| 122 | if (n == 0) break; | ||
| 123 | try got.appendSlice(alloc, buf[0..n]); | ||
| 124 | } | ||
| 125 | try std.testing.expectEqualStrings("alpha-beta-gamma", got.items); | ||
| 126 | } | ||
| 127 | |||
| 128 | test "DELAY_MS is read from the environment, and a bad value is not fatal" { | ||
| 129 | // Nothing set in the test environment, so this is the default path — | ||
| 130 | // the one a bare `delaypipe` in a pipeline takes. | ||
| 131 | try std.testing.expectEqual(default_delay_ms, delayFromEnv()); | ||
| 132 | } | ||
test/rawmode.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,181 @@ | |||
| 1 | //! A deterministic stand-in for an editor, for tests about prediction. | ||
| 2 | //! | ||
| 3 | //! Prediction's hardest tier is raw mode, where the kernel echoes nothing | ||
| 4 | //! and the application decides what a keystroke looks like. nvim is the real | ||
| 5 | //! thing, and it is the wrong thing to put in an automated suite: its redraw | ||
| 6 | //! timing is its own business, its version changes what it paints, and it is | ||
| 7 | //! not installed everywhere this has to run. So the demo keeps nvim and the | ||
| 8 | //! suite gets this — a program with exactly the two behaviours that matter. | ||
| 9 | //! | ||
| 10 | //! phase 1, `.echo` — every byte is written straight back, which is | ||
| 11 | //! what an editor in insert mode looks like from | ||
| 12 | //! outside: predictions confirm. | ||
| 13 | //! phase 2, `.swallow` — bytes are consumed and nothing is printed, which | ||
| 14 | //! is normal mode: predictions go unanswered, and | ||
| 15 | //! the expiry bound is what has to catch them. | ||
| 16 | //! | ||
| 17 | //! `0x00` moves it from the first to the second, once and not back — a test | ||
| 18 | //! wants to cross that boundary at a moment it chose. `0x03` ends it. | ||
| 19 | //! | ||
| 20 | //! Raw mode is set on the tty when there is one, so the LINE DISCIPLINE | ||
| 21 | //! echoes nothing and this program's own writes are the only echo. That is | ||
| 22 | //! what makes the daemon report icanon=0/echo=0 and the client's overlay | ||
| 23 | //! reach `.adaptive`, which is the whole point of the helper. | ||
| 24 | const std = @import("std"); | ||
| 25 | |||
| 26 | pub const toggle_byte: u8 = 0x00; | ||
| 27 | pub const quit_byte: u8 = 0x03; | ||
| 28 | |||
| 29 | pub const Phase = enum { echo, swallow }; | ||
| 30 | |||
| 31 | pub const Action = union(enum) { | ||
| 32 | /// Write it back: insert mode. | ||
| 33 | emit: u8, | ||
| 34 | /// Consume it and print nothing: normal mode. | ||
| 35 | swallow, | ||
| 36 | /// Done. | ||
| 37 | quit, | ||
| 38 | }; | ||
| 39 | |||
| 40 | /// The whole behaviour, as a function of one byte. Pulled out of the loop so | ||
| 41 | /// the state machine can be tested without a process, a pipe or a tty. | ||
| 42 | pub fn step(phase: *Phase, byte: u8) Action { | ||
| 43 | if (byte == quit_byte) return .quit; | ||
| 44 | if (byte == toggle_byte) { | ||
| 45 | // One-way, deliberately: a test crosses the boundary once, at a | ||
| 46 | // moment it picked, and everything after that is normal mode. | ||
| 47 | phase.* = .swallow; | ||
| 48 | return .swallow; | ||
| 49 | } | ||
| 50 | return switch (phase.*) { | ||
| 51 | .echo => .{ .emit = byte }, | ||
| 52 | .swallow => .swallow, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | /// Run the machine over a pair of descriptors until the quit byte or EOF. | ||
| 57 | /// Closes neither: whoever opened them owns them. | ||
| 58 | pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t) !void { | ||
| 59 | var phase: Phase = .echo; | ||
| 60 | var buf: [4096]u8 = undefined; | ||
| 61 | while (true) { | ||
| 62 | const n = std.posix.read(in_fd, &buf) catch return; | ||
| 63 | if (n == 0) return; | ||
| 64 | for (buf[0..n]) |byte| { | ||
| 65 | switch (step(&phase, byte)) { | ||
| 66 | .quit => return, | ||
| 67 | .swallow => {}, | ||
| 68 | .emit => |b| { | ||
| 69 | var one = [_]u8{b}; | ||
| 70 | writeAll(out_fd, &one) catch return; | ||
| 71 | }, | ||
| 72 | } | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | fn writeAll(fd: std.posix.fd_t, data: []const u8) !void { | ||
| 78 | var i: usize = 0; | ||
| 79 | while (i < data.len) i += try std.posix.write(fd, data[i..]); | ||
| 80 | } | ||
| 81 | |||
| 82 | pub fn main() !void { | ||
| 83 | const in_fd = std.posix.STDIN_FILENO; | ||
| 84 | const out_fd = std.posix.STDOUT_FILENO; | ||
| 85 | |||
| 86 | // Raw mode when there is a terminal to put into it. Without this the | ||
| 87 | // line discipline echoes for us and the pty reports icanon=1/echo=1, | ||
| 88 | // which is the one tier this helper exists NOT to be. | ||
| 89 | var restore: ?std.posix.termios = null; | ||
| 90 | if (std.posix.isatty(in_fd)) { | ||
| 91 | const orig = try std.posix.tcgetattr(in_fd); | ||
| 92 | restore = orig; | ||
| 93 | var raw = orig; | ||
| 94 | raw.lflag.ICANON = false; | ||
| 95 | raw.lflag.ECHO = false; | ||
| 96 | raw.lflag.ISIG = false; | ||
| 97 | try std.posix.tcsetattr(in_fd, .FLUSH, raw); | ||
| 98 | } | ||
| 99 | defer if (restore) |t| std.posix.tcsetattr(in_fd, .FLUSH, t) catch {}; | ||
| 100 | |||
| 101 | try pump(in_fd, out_fd); | ||
| 102 | } | ||
| 103 | |||
| 104 | test "step: insert mode echoes, normal mode does not, and the toggle is one-way" { | ||
| 105 | var phase: Phase = .echo; | ||
| 106 | |||
| 107 | // Insert-like: every printable comes straight back. | ||
| 108 | try std.testing.expectEqual(Action{ .emit = 'a' }, step(&phase, 'a')); | ||
| 109 | try std.testing.expectEqual(Action{ .emit = 'Z' }, step(&phase, 'Z')); | ||
| 110 | try std.testing.expectEqual(Phase.echo, phase); | ||
| 111 | |||
| 112 | // The boundary itself prints nothing. | ||
| 113 | try std.testing.expectEqual(Action.swallow, step(&phase, toggle_byte)); | ||
| 114 | try std.testing.expectEqual(Phase.swallow, phase); | ||
| 115 | |||
| 116 | // Normal-like: consumed, and the screen never hears about it. This is | ||
| 117 | // the shape that leaves a prediction unanswered forever, which is what | ||
| 118 | // the overlay's expiry bound exists for. | ||
| 119 | try std.testing.expectEqual(Action.swallow, step(&phase, 'a')); | ||
| 120 | try std.testing.expectEqual(Action.swallow, step(&phase, 'j')); | ||
| 121 | |||
| 122 | // And it does not go back: a second toggle is still normal mode, so a | ||
| 123 | // test that sends one cannot accidentally re-arm the echo it was | ||
| 124 | // finished with. | ||
| 125 | try std.testing.expectEqual(Action.swallow, step(&phase, toggle_byte)); | ||
| 126 | try std.testing.expectEqual(Phase.swallow, phase); | ||
| 127 | |||
| 128 | try std.testing.expectEqual(Action.quit, step(&phase, quit_byte)); | ||
| 129 | } | ||
| 130 | |||
| 131 | test "step: quit wins even in insert mode, where every other byte echoes" { | ||
| 132 | var phase: Phase = .echo; | ||
| 133 | try std.testing.expectEqual(Action.quit, step(&phase, quit_byte)); | ||
| 134 | } | ||
| 135 | |||
| 136 | fn pumpThread(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t) void { | ||
| 137 | pump(in_fd, out_fd) catch {}; | ||
| 138 | } | ||
| 139 | |||
| 140 | test "pump over a pipe pair: the echo stops exactly where the toggle is" { | ||
| 141 | const alloc = std.testing.allocator; | ||
| 142 | const to_child = try std.posix.pipe(); | ||
| 143 | const from_child = try std.posix.pipe(); | ||
| 144 | defer std.posix.close(to_child[0]); | ||
| 145 | defer std.posix.close(from_child[0]); | ||
| 146 | |||
| 147 | const th = try std.Thread.spawn(.{}, pumpThread, .{ to_child[0], from_child[1] }); | ||
| 148 | |||
| 149 | // "hi" in insert mode, then the boundary, then "xy" that must vanish, | ||
| 150 | // then quit. One write, so the pump sees them as one chunk and has to | ||
| 151 | // change behaviour mid-buffer rather than between reads. | ||
| 152 | try writeAll(to_child[1], "hi\x00xy\x03"); | ||
| 153 | th.join(); | ||
| 154 | std.posix.close(to_child[1]); | ||
| 155 | std.posix.close(from_child[1]); | ||
| 156 | |||
| 157 | var out: std.ArrayList(u8) = .empty; | ||
| 158 | defer out.deinit(alloc); | ||
| 159 | var buf: [256]u8 = undefined; | ||
| 160 | while (true) { | ||
| 161 | const n = try std.posix.read(from_child[0], &buf); | ||
| 162 | if (n == 0) break; | ||
| 163 | try out.appendSlice(alloc, buf[0..n]); | ||
| 164 | } | ||
| 165 | // Exactly the insert-mode bytes: "xy" was typed and answered with | ||
| 166 | // silence, which is the behaviour prediction has to survive. | ||
| 167 | try std.testing.expectEqualStrings("hi", out.items); | ||
| 168 | } | ||
| 169 | |||
| 170 | test "pump: EOF ends it as cleanly as the quit byte" { | ||
| 171 | const to_child = try std.posix.pipe(); | ||
| 172 | const from_child = try std.posix.pipe(); | ||
| 173 | defer std.posix.close(to_child[0]); | ||
| 174 | defer std.posix.close(from_child[0]); | ||
| 175 | defer std.posix.close(from_child[1]); | ||
| 176 | |||
| 177 | const th = try std.Thread.spawn(.{}, pumpThread, .{ to_child[0], from_child[1] }); | ||
| 178 | try writeAll(to_child[1], "ab"); | ||
| 179 | std.posix.close(to_child[1]); // EOF, with no quit byte at all | ||
| 180 | th.join(); // returning at all is the assertion | ||
| 181 | } | ||