test/delaypipe.zig
Ref: Size: 6.7 KiB History
//! A byte pump that adds a fixed one-way delay, so a round trip can be made
//! slow without root.
//!
//! Every latency measurement so far has needed `tc netem` on a real box,
//! which means a machine, an interface, and sudo. Prediction's whole claim
//! is that echo latency stops depending on the round trip, and demonstrating
//! that needs a round trip long enough to see — but not a real one.
//!
//! `--via` runs its argument through `/bin/sh -c`, so two of these compose
//! into a symmetric delay with a shell pipeline and nothing else:
//!
//! mux --via "delaypipe | mux d proxy --sock S | delaypipe"
//!
//! The client's bytes go through the first before reaching the daemon, the
//! daemon's answers through the second on the way back: DELAY_MS each way,
//! 2*DELAY_MS round trip, no privileges anywhere.
//!
//! The delay is applied per chunk and serially — a chunk is read, held, and
//! written before the next read — so throughput is capped at one chunk per
//! DELAY_MS. That is fine for what this is for (a person typing, and a shell
//! answering) and would be wrong for a throughput benchmark. Said plainly
//! because the cap is invisible until something measures against it: a burst
//! arriving while a chunk is being held is coalesced into the next one, so
//! bulk output is delayed once rather than once per byte.
const std = @import("std");
pub const default_delay_ms: u64 = 150;
/// Pump `in_fd` to `out_fd`, holding every chunk for `delay_ms` first.
/// Returns at EOF or on any error. Closes neither descriptor.
pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, delay_ms: u64) void {
var buf: [64 * 1024]u8 = undefined;
while (true) {
const n = std.posix.read(in_fd, &buf) catch return;
if (n == 0) return;
// Held after arrival and before delivery, which is what makes the
// delay a property of the path rather than of the sender.
std.Thread.sleep(delay_ms * std.time.ns_per_ms);
writeAll(out_fd, buf[0..n]) catch return;
}
}
fn writeAll(fd: std.posix.fd_t, data: []const u8) !void {
var i: usize = 0;
while (i < data.len) i += try std.posix.write(fd, data[i..]);
}
/// Interpret a `DELAY_MS` value. Absent or unparseable is the default: this
/// is a test helper, and failing to start would look like a broken transport
/// rather than a typo.
///
/// Split from the lookup so the interpretation is testable on its own —
/// std has no portable setenv, so a test that went through the environment
/// could only ever exercise the unset case, which is the one branch that
/// returns the default no matter how badly the lookup is broken.
pub fn parseDelay(raw: ?[]const u8) u64 {
const s = raw orelse return default_delay_ms;
return std.fmt.parseInt(u64, s, 10) catch default_delay_ms;
}
pub fn delayFromEnv() u64 {
return parseDelay(std.posix.getenv("DELAY_MS"));
}
pub fn main() void {
pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, delayFromEnv());
}
const Harness = struct {
to: [2]std.posix.fd_t,
from: [2]std.posix.fd_t,
thread: std.Thread,
fn start(delay_ms: u64) !Harness {
const to = try std.posix.pipe();
const from = try std.posix.pipe();
return .{
.to = to,
.from = from,
.thread = try std.Thread.spawn(.{}, pump, .{ to[0], from[1], delay_ms }),
};
}
fn finish(self: *Harness) void {
std.posix.close(self.to[1]); // EOF ends the pump
self.thread.join();
std.posix.close(self.to[0]);
std.posix.close(self.from[0]);
std.posix.close(self.from[1]);
}
};
test "a byte arrives no earlier than the delay, and arrives intact" {
var h = try Harness.start(120);
defer h.finish();
const start = std.time.milliTimestamp();
try writeAll(h.to[1], "x");
var buf: [16]u8 = undefined;
const n = try std.posix.read(h.from[0], &buf);
const elapsed = std.time.milliTimestamp() - start;
try std.testing.expectEqualStrings("x", buf[0..n]);
// The lower bound is the whole point, and it is asserted with a little
// room for clock granularity rather than at exactly 120: sleep promises
// at least its duration, and a millisecond of rounding either way must
// not make this flaky. A pump that forgot to wait lands near zero and
// fails this by a hundred milliseconds, not by one.
try std.testing.expect(elapsed >= 100);
}
test "a payload larger than one write survives the crossing in order" {
var h = try Harness.start(5);
defer h.finish();
// Written in pieces, so the pump sees several chunks and each one is
// delayed independently. Order is the property under test: a pump that
// held chunks concurrently could deliver them out of sequence, and a
// transport that reorders bytes is not a transport.
try writeAll(h.to[1], "alpha-");
try writeAll(h.to[1], "beta-");
try writeAll(h.to[1], "gamma");
const alloc = std.testing.allocator;
var got: std.ArrayList(u8) = .empty;
defer got.deinit(alloc);
var buf: [64]u8 = undefined;
while (got.items.len < "alpha-beta-gamma".len) {
const n = try std.posix.read(h.from[0], &buf);
if (n == 0) break;
try got.appendSlice(alloc, buf[0..n]);
}
try std.testing.expectEqualStrings("alpha-beta-gamma", got.items);
}
test "DELAY_MS is honoured when set, and never fatal when it is nonsense" {
// The case that matters: a value that is NOT the default, because a
// lookup that silently failed would fall back to the default and every
// measurement taken through this pipe would quietly be of the wrong
// path. e2e types into a 400ms path and asserts the round trip is
// inconsistent with 150, which is the other half of this.
try std.testing.expectEqual(@as(u64, 250), parseDelay("250"));
try std.testing.expectEqual(@as(u64, 0), parseDelay("0"));
// Nonsense is the default rather than a failure to start: a helper that
// refused to run would read as a broken transport in the suite that
// used it, which is a much longer way round to finding a typo.
try std.testing.expectEqual(default_delay_ms, parseDelay("abc"));
try std.testing.expectEqual(default_delay_ms, parseDelay(""));
try std.testing.expectEqual(default_delay_ms, parseDelay("-5"));
// Unset, which is what a bare `delaypipe` in a pipeline takes.
try std.testing.expectEqual(default_delay_ms, parseDelay(null));
try std.testing.expectEqual(default_delay_ms, delayFromEnv());
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}