a73x

b96a01d5

test: rawmode and delaypipe — deterministic mode transitions and root-free RTT

a73x   2026-08-08 15:52

Commit message
test: rawmode and delaypipe — deterministic mode transitions and root-free RTT

Two helpers the prediction suite cannot be honest without.

rawmode is a stand-in for an editor, with exactly the two behaviours that
matter: it echoes every byte back (insert mode, where predictions confirm),
and after 0x00 it consumes them and prints nothing (normal mode, where a
prediction is answered by silence and only the expiry bound can retire it).
It sets raw mode on the tty, so the line discipline echoes nothing and its
own writes are the only echo — which is what makes the daemon report
icanon=0/echo=0 and drives the client into the adaptive tier. nvim is the
real thing and stays the manual demo: its redraw timing is its own
business, its version changes what it paints, and it is not installed
everywhere this has to run.

delaypipe holds each chunk for DELAY_MS before passing it on. Since --via
runs through /bin/sh -c, two of them compose into a symmetric round trip
with a shell pipeline and no privileges at all:
  mux --via "delaypipe | muxd proxy --sock S | delaypipe"
Throughput is capped at one chunk per DELAY_MS because the delay is serial;
said plainly in the module doc, because that cap is invisible until
something measures against it.

Both are wired into the test list in this commit, not a later one — a
module absent from that list is silently never run.

Verified as binaries, not merely as unit tests. Over a composed 150ms-each-
way path a keystroke paints its underlined prediction immediately and the
daemon's own row arrives 300ms later: made=1 displayed=1 confirmed=1. With
rawmode as the session shell, four keystrokes give made=4 displayed=2
confirmed=4 — the first two invisible while promotion is earned, and the
painted bytes contain exactly \e[4mc and \e[4md. Crossing into normal mode
and typing one more gives confirmed=3 contradicted=1 expired=1
abandoned=1: the phantom guard retiring a keystroke the application
swallowed, with made = confirmed + abandoned + pending holding throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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 }