Files
irtt-rs/scripts/compare_irtt_clients.py
T
Nils Andreas Svee 7bea3dd54e refactor(app): clarify application and binary roles
Rename the application Cargo package from irtt-cli to irtt-rs
(crates/irtt-cli -> crates/irtt-app), and hard-rename its scriptable
client binary from irtt-cli to irtt-client, now that the application
also ships a first-class server and the old name reads as an umbrella
CLI for both roles.

Give each dedicated binary (irtt-client, irtt-tui, irtt-server) its
own entry point instead of funneling through the multicall
dispatcher's argv0/subcommand logic, so a dedicated binary only links
the applet code for the role it implements and always runs that role
regardless of the name it's invoked or copied under. irtt-rs remains
the feature-selectable multicall dispatcher and is now the only binary
that inspects argv0 or subcommands.

Move future application releases to the irtt-rs/vX.Y.Z tag namespace
(regenerated via `dist generate`), and bump the application package to
0.6.0 as a breaking distribution change. Library crate versions are
unaffected.
2026-08-20 11:44:35 +07:00

1138 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""Run paired upstream irtt and irtt-rs client comparisons.
This is a black-box interoperability harness. It never reads upstream source; it
only invokes the upstream `irtt` executable and compares its captured output with
the local Rust client output.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import json
import math
import os
import re
import shlex
import socket
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from statistics import mean, median
from typing import Any
DEFAULT_REMOTE_TARGETS = [
("netperf-eu.bufferbloat.net", "15s", "250ms", "remote-eu"),
("45.128.222.138", "15s", "250ms", "remote-singapore"),
]
DEFAULT_LOCAL_TARGET = ("127.0.0.1", "10s", "100ms", "local")
DEFAULT_OUTPUT_ROOT = Path("target/interop-comparisons")
DEFAULT_IRTT_RS_COMMAND = "target/debug/irtt-client"
@dataclass
class CommandResult:
name: str
command: list[str]
started_at: str
finished_at: str
elapsed_seconds: float
returncode: int
stdout_path: str
stderr_path: str
extra_paths: dict[str, str] = field(default_factory=dict)
@dataclass
class PairResult:
upstream: CommandResult
rust: CommandResult
start_skew_ms: float
@dataclass
class Metrics:
source: str
sent: int | None = None
received: int | None = None
lost: int | None = None
loss_percent: float | None = None
rtt_min_us: float | None = None
rtt_mean_us: float | None = None
rtt_median_us: float | None = None
rtt_max_us: float | None = None
raw_rtt_min_us: float | None = None
raw_rtt_mean_us: float | None = None
raw_rtt_median_us: float | None = None
raw_rtt_max_us: float | None = None
adjusted_rtt_min_us: float | None = None
adjusted_rtt_mean_us: float | None = None
adjusted_rtt_median_us: float | None = None
adjusted_rtt_max_us: float | None = None
samples_path: str | None = None
first_send_wall_ns: int | None = None
parse_notes: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
key: value
for key, value in self.__dict__.items()
if value is not None and value != []
}
@dataclass
class TestCase:
name: str
target: str
duration: str
interval: str
is_local: bool = False
def utc_now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds")
def safe_name(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") or "target"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Compare upstream irtt and irtt-rs clients with concurrent runs."
)
parser.add_argument(
"--target",
action="append",
help=(
"Target host or host:port. Repeat to run multiple custom targets. "
"When omitted, the default remote targets plus localhost are used."
),
)
parser.add_argument(
"--duration",
help="Override all test durations, for example 10s or 1500ms.",
)
parser.add_argument(
"--interval",
help="Override all probe intervals, for example 250ms.",
)
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_ROOT),
help=f"Directory for run artifacts (default: {DEFAULT_OUTPUT_ROOT}).",
)
parser.add_argument(
"--remote-only",
action="store_true",
help="Run only remote/default or explicitly supplied non-local targets.",
)
parser.add_argument(
"--include-local",
action="store_true",
help="Also run the localhost test when custom --target values are supplied.",
)
parser.add_argument(
"--upstream-irtt",
default="irtt",
help="Path to the upstream irtt executable (default: irtt).",
)
parser.add_argument(
"--irtt-rs-command",
default=DEFAULT_IRTT_RS_COMMAND,
help=(
"Command prefix or direct path for the Rust client. Arguments for "
"target, duration, interval, and CSV event output are appended. "
f"Default: {DEFAULT_IRTT_RS_COMMAND!r}"
),
)
parser.add_argument(
"--client-command-prefix",
default="",
help=(
"Command prefix applied to both client invocations, for example "
"'sudo ip netns exec irtt-client'."
),
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Do not run cargo build -p irtt-rs --bin irtt-client before comparisons.",
)
parser.add_argument(
"--localhost-port",
type=int,
default=2112,
help="Port used by the localhost upstream irtt server check (default: 2112).",
)
parser.add_argument(
"--upstream-extra-arg",
action="append",
default=[],
help="Extra argument passed to upstream `irtt client`; repeat as needed.",
)
parser.add_argument(
"--rust-extra-arg",
action="append",
default=[],
help="Extra argument passed to irtt-rs; repeat as needed.",
)
return parser.parse_args()
def build_cases(args: argparse.Namespace) -> list[TestCase]:
cases: list[TestCase] = []
if args.target:
for target in args.target:
target_host, target_port = split_host_port(target)
is_local = target_host in {"127.0.0.1", "localhost", "::1"}
if args.remote_only and is_local:
continue
cases.append(
TestCase(
name=safe_name(target),
target=target,
duration=args.duration or "15s",
interval=args.interval or "250ms",
is_local=is_local,
)
)
if args.include_local and not args.remote_only:
host, duration, interval, name = DEFAULT_LOCAL_TARGET
cases.append(
TestCase(
name=name,
target=f"{host}:{args.localhost_port}",
duration=args.duration or duration,
interval=args.interval or interval,
is_local=True,
)
)
else:
for host, duration, interval, name in DEFAULT_REMOTE_TARGETS:
cases.append(
TestCase(
name=name,
target=host,
duration=args.duration or duration,
interval=args.interval or interval,
)
)
if not args.remote_only:
host, duration, interval, name = DEFAULT_LOCAL_TARGET
cases.append(
TestCase(
name=name,
target=f"{host}:{args.localhost_port}",
duration=args.duration or duration,
interval=args.interval or interval,
is_local=True,
)
)
return cases
def split_host_port(target: str) -> tuple[str, int | None]:
if target.startswith("["):
match = re.match(r"^\[([^\]]+)\](?::(\d+))?$", target)
if match:
port = int(match.group(2)) if match.group(2) else None
return match.group(1), port
return target, None
if target.count(":") == 1:
host, port = target.rsplit(":", 1)
if port.isdigit():
return host, int(port)
return target, None
def local_instruction(port: int, upstream: str) -> str:
return (
f"localhost:{port} is not reachable by upstream irtt no-test. "
"Start a local upstream server manually, for example:\n\n"
f" {shlex.quote(upstream)} server -b 127.0.0.1:{port} --tstamp=dual\n\n"
"Then rerun this harness, or pass --remote-only to intentionally skip "
"the local test."
)
def classify_local_preflight_failure(
exc: BaseException | None, stdout: str = "", stderr: str = ""
) -> str:
text = f"{exc or ''}\n{stdout}\n{stderr}".lower()
if isinstance(exc, subprocess.TimeoutExpired) or any(
marker in text for marker in ["timed out", "timeout", "i/o timeout", "deadline exceeded"]
):
return "timeout: no response/firewall"
if any(
marker in text
for marker in [
"connection refused",
"connection reset by peer",
"connect: refused",
"actively refused",
]
):
return "connection refused: likely no local server"
if isinstance(exc, PermissionError) or any(
marker in text
for marker in [
"permission denied",
"operation not permitted",
"address already in use",
"bind: permission",
"bind: address",
]
):
return "permission/bind error: local environment permission issue"
return "other: unknown local preflight failure"
def check_local_server(args: argparse.Namespace) -> tuple[bool, str]:
target = f"127.0.0.1:{args.localhost_port}"
command = [
args.upstream_irtt,
"client",
"-n",
"-Q",
"--timeouts=1s",
target,
]
try:
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=3,
)
except (OSError, subprocess.TimeoutExpired) as exc:
classification = classify_local_preflight_failure(exc)
return (
False,
f"{local_instruction(args.localhost_port, args.upstream_irtt)}\n"
f"check classification: {classification}\n"
f"check error: {exc}",
)
if completed.returncode == 0:
return True, ""
details = (completed.stderr or completed.stdout or "").strip()
classification = classify_local_preflight_failure(
None, completed.stdout or "", completed.stderr or ""
)
suffix = f"\ncheck output:\n{details}" if details else ""
return (
False,
local_instruction(args.localhost_port, args.upstream_irtt)
+ f"\ncheck classification: {classification}"
+ suffix,
)
def preflight_build_irtt_client(run_dir: Path) -> bool:
command = ["cargo", "build", "-p", "irtt-rs", "--bin", "irtt-client"]
started = utc_now()
print(f"[{started}] preflight build: {shlex.join(command)}", flush=True)
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
(run_dir / "preflight-build.stdout").write_text(completed.stdout, encoding="utf-8")
(run_dir / "preflight-build.stderr").write_text(completed.stderr, encoding="utf-8")
if completed.returncode != 0:
print(
f"preflight build failed with exit {completed.returncode}; "
f"see {run_dir / 'preflight-build.stderr'}",
file=sys.stderr,
)
return False
return True
def run_case(
case: TestCase, run_dir: Path, args: argparse.Namespace
) -> tuple[dict[str, Any], bool]:
case_dir = run_dir / case.name
case_dir.mkdir(parents=True, exist_ok=True)
upstream_json = case_dir / "upstream.json"
client_prefix = shlex.split(args.client_command_prefix)
upstream_cmd = [
*client_prefix,
args.upstream_irtt,
"client",
"-d",
case.duration,
"-i",
case.interval,
"-q",
"-o",
str(upstream_json),
*args.upstream_extra_arg,
case.target,
]
rust_cmd = [
*client_prefix,
*shlex.split(args.irtt_rs_command),
case.target,
"--duration",
case.duration,
"--interval",
case.interval,
"--format",
"csv",
"--columns",
"event,seq,effective_rtt_us,raw_rtt_us,adjusted_rtt_us,server_processing_us,client_send_wall_ns",
*args.rust_extra_arg,
]
print(
f"[{utc_now()}] running {case.name}: target={case.target} "
f"duration={case.duration} interval={case.interval}",
flush=True,
)
pair_result = run_pair(case_dir, upstream_cmd, rust_cmd)
upstream_result = pair_result.upstream
rust_result = pair_result.rust
rust_samples = case_dir / "irtt-rs-rtt-samples.csv"
upstream_samples = case_dir / "upstream-rtt-samples.csv"
rust_metrics = parse_rust_csv(case_dir / "irtt-rs.stdout", rust_samples)
upstream_metrics = parse_upstream(case_dir / "upstream.stdout", upstream_json, upstream_samples)
comparison = classify_difference(upstream_metrics, rust_metrics)
first_send_skew_ms = paired_first_send_skew_ms(upstream_metrics, rust_metrics)
result = {
"case": case.__dict__,
"upstream": upstream_result.__dict__,
"irtt_rs": rust_result.__dict__,
"paired_start_skew_ms": pair_result.start_skew_ms,
"paired_first_send_skew_ms": first_send_skew_ms,
"metrics": {
"upstream": upstream_metrics.as_dict(),
"irtt_rs": rust_metrics.as_dict(),
},
"comparison": comparison,
}
write_json(case_dir / "comparison.json", result)
write_case_summary(case_dir / "summary.md", result)
ok = upstream_result.returncode == 0 and rust_result.returncode == 0
return result, ok
def paired_first_send_skew_ms(upstream: Metrics, rust: Metrics) -> float | None:
if upstream.first_send_wall_ns is None or rust.first_send_wall_ns is None:
return None
return (rust.first_send_wall_ns - upstream.first_send_wall_ns) / 1_000_000.0
def run_pair(
case_dir: Path, upstream_cmd: list[str], rust_cmd: list[str]
) -> PairResult:
(case_dir / "commands.txt").write_text(
"upstream: " + shlex.join(upstream_cmd) + "\n"
"irtt-rs: " + shlex.join(rust_cmd) + "\n",
encoding="utf-8",
)
upstream_stdout = case_dir / "upstream.stdout"
upstream_stderr = case_dir / "upstream.stderr"
rust_stdout = case_dir / "irtt-rs.stdout"
rust_stderr = case_dir / "irtt-rs.stderr"
upstream_out = upstream_stdout.open("wb")
upstream_err = upstream_stderr.open("wb")
upstream_started = utc_now()
upstream_start_mono = time.monotonic()
try:
upstream_proc = subprocess.Popen(
upstream_cmd,
stdout=upstream_out,
stderr=upstream_err,
)
except OSError as exc:
upstream_out.close()
upstream_err.close()
upstream_stderr.write_text(f"failed to start upstream irtt: {exc}\n", encoding="utf-8")
upstream_proc = None
rust_out = rust_stdout.open("wb")
rust_err = rust_stderr.open("wb")
rust_started = utc_now()
rust_start_mono = time.monotonic()
try:
rust_proc = subprocess.Popen(
rust_cmd,
stdout=rust_out,
stderr=rust_err,
)
except OSError as exc:
rust_out.close()
rust_err.close()
rust_stderr.write_text(f"failed to start irtt-rs: {exc}\n", encoding="utf-8")
rust_proc = None
upstream_rc = wait_process(upstream_proc)
upstream_finished = utc_now()
upstream_elapsed = time.monotonic() - upstream_start_mono
rust_rc = wait_process(rust_proc)
rust_finished = utc_now()
rust_elapsed = time.monotonic() - rust_start_mono
for handle in [upstream_out, upstream_err, rust_out, rust_err]:
try:
handle.close()
except Exception:
pass
return PairResult(
upstream=CommandResult(
name="upstream",
command=upstream_cmd,
started_at=upstream_started,
finished_at=upstream_finished,
elapsed_seconds=upstream_elapsed,
returncode=upstream_rc,
stdout_path=str(upstream_stdout),
stderr_path=str(upstream_stderr),
extra_paths={"json": str(case_dir / "upstream.json")},
),
rust=CommandResult(
name="irtt-rs",
command=rust_cmd,
started_at=rust_started,
finished_at=rust_finished,
elapsed_seconds=rust_elapsed,
returncode=rust_rc,
stdout_path=str(rust_stdout),
stderr_path=str(rust_stderr),
),
start_skew_ms=(rust_start_mono - upstream_start_mono) * 1000.0,
)
def wait_process(proc: subprocess.Popen[bytes] | None) -> int:
if proc is None:
return 127
try:
return proc.wait()
except KeyboardInterrupt:
proc.terminate()
try:
return proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return proc.wait()
def parse_rust_csv(path: Path, samples_path: Path) -> Metrics:
metrics = Metrics(source="irtt-rs", samples_path=str(samples_path))
effective: list[float] = []
raw: list[float] = []
adjusted: list[float] = []
sent_seqs: set[int] = set()
received_seqs: set[int] = set()
lost = 0
rows = ["seq,effective_rtt_us,raw_rtt_us,adjusted_rtt_us,server_processing_us\n"]
if not path.exists():
metrics.parse_notes.append(f"missing {path}")
return metrics
text = path.read_text(encoding="utf-8", errors="replace")
for fields in csv.DictReader(line for line in text.splitlines() if line.strip()):
event = fields.get("event")
if event is None:
continue
seq = parse_int(fields.get("seq"))
if event == "echo_reply":
if seq is not None:
received_seqs.add(seq)
sent_seqs.add(seq)
effective_value = parse_float(fields.get("effective_rtt_us"))
raw_value = parse_float(fields.get("raw_rtt_us"))
adjusted_value = parse_float(fields.get("adjusted_rtt_us"))
server_processing = parse_float(fields.get("server_processing_us"))
append_if_number(effective, effective_value)
append_if_number(raw, raw_value)
append_if_number(adjusted, adjusted_value)
if metrics.first_send_wall_ns is None:
metrics.first_send_wall_ns = parse_int(fields.get("client_send_wall_ns"))
rows.append(
f"{blank_none(seq)},{blank_none(effective_value)},{blank_none(raw_value)},"
f"{blank_none(adjusted_value)},{blank_none(server_processing)}\n"
)
elif event == "loss":
lost += 1
if seq is not None:
sent_seqs.add(seq)
elif event in {"duplicate", "late"}:
metrics.parse_notes.append(f"excluded {event} event from primary RTT stats")
samples_path.write_text("".join(rows), encoding="utf-8")
metrics.sent = len(sent_seqs) if sent_seqs else None
metrics.received = len(received_seqs)
metrics.lost = lost if lost else (
metrics.sent - metrics.received if metrics.sent is not None else None
)
if metrics.sent:
metrics.loss_percent = 100.0 * (metrics.lost or 0) / metrics.sent
fill_stats(metrics, effective, "rtt")
fill_stats(metrics, raw, "raw_rtt")
fill_stats(metrics, adjusted, "adjusted_rtt")
if not effective:
metrics.parse_notes.append("no echo_reply effective_rtt_us samples parsed")
return metrics
def parse_upstream(stdout_path: Path, json_path: Path, samples_path: Path) -> Metrics:
metrics = Metrics(source="upstream", samples_path=str(samples_path))
samples: list[float] = []
if json_path.exists():
try:
data = json.loads(json_path.read_text(encoding="utf-8", errors="replace"))
extract_upstream_json_metrics(data, metrics, samples)
except json.JSONDecodeError as exc:
metrics.parse_notes.append(f"failed to parse upstream JSON: {exc}")
else:
metrics.parse_notes.append(f"missing upstream JSON output: {json_path}")
if stdout_path.exists():
parse_upstream_text(stdout_path.read_text(encoding="utf-8", errors="replace"), metrics)
samples_path.write_text(
"rtt_us\n" + "".join(f"{value}\n" for value in samples),
encoding="utf-8",
)
if samples:
fill_stats(metrics, samples, "rtt")
if not samples and all(
value is None
for value in [
metrics.rtt_min_us,
metrics.rtt_mean_us,
metrics.rtt_median_us,
metrics.rtt_max_us,
]
):
metrics.parse_notes.append(
"upstream RTT samples/stats were not recognized; inspect upstream.json manually"
)
return metrics
def extract_upstream_json_metrics(data: Any, metrics: Metrics, samples: list[float]) -> None:
metrics.first_send_wall_ns = extract_upstream_first_send_wall_ns(data)
for key, value in walk_items(data):
key_norm = leaf_key(key)
if metrics.sent is None and key_norm in {
"packetssent",
"packetssend",
"sent",
"send",
"npackets",
}:
metrics.sent = parse_int(value)
elif metrics.received is None and key_norm in {
"packetsreceived",
"packetsrecv",
"received",
"recv",
}:
metrics.received = parse_int(value)
elif metrics.lost is None and key_norm in {"packetslost", "lost"}:
metrics.lost = parse_int(value)
elif metrics.loss_percent is None and key_norm in {
"packetlosspercent",
"losspercent",
"losspct",
}:
metrics.loss_percent = parse_float(value)
for key, value in walk_items(data):
if is_upstream_rtt_stats_key(key) and isinstance(value, dict):
apply_stat_dict(metrics, value, "rtt")
collect_upstream_samples(data, samples)
def walk_items(value: Any, parent_key: str = ""):
if isinstance(value, dict):
for key, child in value.items():
joined = f"{parent_key}.{key}" if parent_key else str(key)
yield joined, child
yield from walk_items(child, joined)
elif isinstance(value, list):
for index, child in enumerate(value):
yield from walk_items(child, f"{parent_key}[{index}]")
def normalize_key(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", value.lower())
def leaf_key(value: str) -> str:
leaf = re.split(r"[.\[]", value)[-1].rstrip("]")
return normalize_key(leaf)
def is_upstream_rtt_stats_key(key: str) -> bool:
parts = [normalize_key(part) for part in key.split(".")]
if not parts:
return False
leaf = parts[-1]
if leaf not in {"rtt", "roundtrip", "roundtriptime"}:
return False
if len(parts) == 1:
return True
return parts[-2] in {"stats", "statistics", "summary"}
def apply_stat_dict(metrics: Metrics, value: dict[str, Any], prefix: str) -> None:
by_key = {normalize_key(str(key)): child for key, child in value.items()}
for attr, candidates in [
(f"{prefix}_min_us", ["min", "minimum"]),
(f"{prefix}_mean_us", ["mean", "avg", "average"]),
(f"{prefix}_median_us", ["median", "p50"]),
(f"{prefix}_max_us", ["max", "maximum"]),
]:
if getattr(metrics, attr) is not None:
continue
for candidate in candidates:
if candidate in by_key:
parsed = parse_duration_to_us(by_key[candidate])
if parsed is not None:
setattr(metrics, attr, parsed)
break
def collect_upstream_samples(value: Any, samples: list[float]) -> None:
if isinstance(value, dict):
for key, child in value.items():
if normalize_key(str(key)) == "roundtrips" and isinstance(child, list):
collect_upstream_round_trip_samples(child, samples)
else:
collect_upstream_samples(child, samples)
elif isinstance(value, list):
for child in value:
collect_upstream_samples(child, samples)
def collect_upstream_round_trip_samples(round_trips: list[Any], samples: list[float]) -> None:
for item in round_trips:
if not isinstance(item, dict):
continue
if str(item.get("lost", "")).lower() == "true":
continue
delay = item.get("delay")
if not isinstance(delay, dict):
continue
parsed = parse_duration_to_us(delay.get("rtt"))
if parsed is not None:
samples.append(parsed)
def extract_upstream_first_send_wall_ns(data: Any) -> int | None:
round_trips = data.get("round_trips") if isinstance(data, dict) else None
if not isinstance(round_trips, list):
return None
for item in round_trips:
if not isinstance(item, dict):
continue
if str(item.get("lost", "")).lower() == "true":
continue
timestamps = item.get("timestamps")
if not isinstance(timestamps, dict):
continue
client = timestamps.get("client")
if not isinstance(client, dict):
continue
send = client.get("send")
if not isinstance(send, dict):
continue
wall = parse_int(send.get("wall"))
if wall is not None:
return wall
return None
def parse_upstream_text(text: str, metrics: Metrics) -> None:
packet_match = re.search(
r"(?P<sent>\d+)\s+packets?\s+sent.*?(?P<received>\d+)\s+"
r"(?:packets?\s+)?received.*?(?P<loss>[0-9.]+)%\s+"
r"(?:packet\s+)?loss",
text,
flags=re.IGNORECASE | re.DOTALL,
)
if packet_match:
metrics.sent = metrics.sent if metrics.sent is not None else int(packet_match.group("sent"))
metrics.received = (
metrics.received
if metrics.received is not None
else int(packet_match.group("received"))
)
metrics.loss_percent = (
metrics.loss_percent
if metrics.loss_percent is not None
else float(packet_match.group("loss"))
)
rtt_match = re.search(
r"(?:rtt|round[- ]trip).*?min/mean/(?:median/)?max.*?=\s*"
r"(?P<min>[0-9.]+)/(?P<mean>[0-9.]+)/(?:(?P<median>[0-9.]+)/)?(?P<max>[0-9.]+)\s*(?P<unit>us|µs|ms|s)?",
text,
flags=re.IGNORECASE | re.DOTALL,
)
if rtt_match:
unit = rtt_match.group("unit") or "ms"
for name in ["min", "mean", "median", "max"]:
value = rtt_match.group(name)
if value is None:
continue
attr = f"rtt_{name}_us"
if getattr(metrics, attr) is None:
setattr(metrics, attr, unit_to_us(float(value), unit))
def run_parser_self_check() -> None:
metrics = Metrics(source="upstream-self-check")
samples: list[float] = []
fixture = {
"stats": {
"rtt": {
"min": "1.2ms",
"mean": "1.3ms",
"median": "1.3ms",
"max": "1.4ms",
},
"ipdv_round_trip": {
"min": "-900us",
"mean": "-100us",
"median": "-50us",
"max": "200us",
},
},
"round_trips": [
{
"timestamps": {"client": {"send": {"wall": 111}}},
"delay": {"rtt": "1.2ms"},
"ipdv": {"rtt": "-900us"},
},
{
"timestamps": {"client": {"send": {"wall": 222}}},
"delay": {"rtt": "1.4ms"},
"ipdv": {"rtt": "-100us"},
},
{
"lost": True,
"delay": {"rtt": "99ms"},
"ipdv": {"rtt": "-99ms"},
},
],
}
extract_upstream_json_metrics(fixture, metrics, samples)
expected_samples = [1200.0, 1400.0]
expected_stats = {
"rtt_min_us": 1200.0,
"rtt_mean_us": 1300.0,
"rtt_median_us": 1300.0,
"rtt_max_us": 1400.0,
}
if samples != expected_samples:
raise RuntimeError(
"upstream parser self-check failed: "
f"expected RTT samples {expected_samples}, got {samples}"
)
for attr, expected in expected_stats.items():
actual = getattr(metrics, attr)
if actual != expected:
raise RuntimeError(
"upstream parser self-check failed: "
f"expected {attr}={expected}, got {actual}"
)
if metrics.first_send_wall_ns != 111:
raise RuntimeError(
"upstream parser self-check failed: "
f"expected first_send_wall_ns=111, got {metrics.first_send_wall_ns}"
)
def classify_difference(upstream: Metrics, rust: Metrics) -> dict[str, Any]:
notes: list[str] = []
deltas: dict[str, Any] = {}
if upstream.sent is not None and rust.sent is not None:
deltas["sent"] = rust.sent - upstream.sent
if abs(deltas["sent"]) > 1:
notes.append("packet count/configuration difference")
if upstream.received is not None and rust.received is not None:
deltas["received"] = rust.received - upstream.received
if abs(deltas["received"]) > 1:
notes.append("received packet/loss difference")
if upstream.loss_percent is not None and rust.loss_percent is not None:
deltas["loss_percent"] = rust.loss_percent - upstream.loss_percent
if abs(deltas["loss_percent"]) >= 5.0:
notes.append("packet loss difference")
if upstream.rtt_mean_us is not None and rust.rtt_mean_us is not None:
deltas["mean_rtt_us"] = rust.rtt_mean_us - upstream.rtt_mean_us
baseline = max(abs(upstream.rtt_mean_us), 1.0)
if abs(deltas["mean_rtt_us"]) / baseline > 0.25 and abs(deltas["mean_rtt_us"]) > 1000:
notes.append(
"RTT difference after parser self-check; potential network/timing difference"
)
if upstream.parse_notes or rust.parse_notes:
notes.append("output parsing limitation")
if not notes:
notes.append("no obvious difference detected by conservative parser")
return {"deltas": deltas, "notes": sorted(set(notes))}
def fill_stats(metrics: Metrics, values: list[float], prefix: str) -> None:
clean = [value for value in values if math.isfinite(value)]
if not clean:
return
setattr(metrics, f"{prefix}_min_us", min(clean))
setattr(metrics, f"{prefix}_mean_us", mean(clean))
setattr(metrics, f"{prefix}_median_us", median(clean))
setattr(metrics, f"{prefix}_max_us", max(clean))
def parse_duration_to_us(value: Any) -> float | None:
if isinstance(value, (int, float)):
# Upstream Go JSON commonly serializes time.Duration as nanoseconds.
return float(value) / 1000.0
if isinstance(value, str):
stripped = value.strip()
numeric = parse_float(stripped)
if numeric is not None:
return numeric / 1000.0
match = re.fullmatch(r"(-?[0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s)", stripped)
if match:
return unit_to_us(float(match.group(1)), match.group(2))
if isinstance(value, dict):
for key in ["us", "usec", "microseconds"]:
if key in value:
return parse_float(value[key])
for key in ["ns", "nsec", "nanoseconds"]:
parsed = parse_float(value[key]) if key in value else None
if parsed is not None:
return parsed / 1000.0
return None
def unit_to_us(value: float, unit: str) -> float:
if unit in {"us", "µs"}:
return value
if unit == "ns":
return value / 1000.0
if unit == "ms":
return value * 1000.0
if unit == "s":
return value * 1_000_000.0
return value
def parse_int(value: Any) -> int | None:
try:
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def parse_float(value: Any) -> float | None:
try:
if value is None:
return None
return float(value)
except (TypeError, ValueError):
return None
def append_if_number(values: list[float], value: float | None) -> None:
if value is not None and math.isfinite(value):
values.append(value)
def blank_none(value: Any) -> str:
return "" if value is None else str(value)
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_case_summary(path: Path, result: dict[str, Any]) -> None:
upstream = result["metrics"]["upstream"]
rust = result["metrics"]["irtt_rs"]
lines = [
f"# {result['case']['name']}",
"",
f"target: `{result['case']['target']}`",
f"duration: `{result['case']['duration']}`",
f"interval: `{result['case']['interval']}`",
f"paired_start_skew_ms: `{result['paired_start_skew_ms']:.3f}`",
"paired_first_send_skew_ms: `"
+ format_optional_float(result.get("paired_first_send_skew_ms"))
+ "`",
"",
"| client | exit | sent | received | loss % | RTT min/mean/median/max us |",
"| --- | ---: | ---: | ---: | ---: | --- |",
summary_row("upstream", result["upstream"]["returncode"], upstream),
summary_row("irtt-rs", result["irtt_rs"]["returncode"], rust),
"",
"## Difference Notes",
]
for note in result["comparison"]["notes"]:
lines.append(f"- {note}")
lines.extend(["", "## irtt-rs RTT Semantics", ""])
lines.append("| field | min/mean/median/max us |")
lines.append("| --- | --- |")
lines.append(
"| effective | "
+ metric_quad(rust, ["rtt_min_us", "rtt_mean_us", "rtt_median_us", "rtt_max_us"])
+ " |"
)
lines.append(
"| raw | "
+ metric_quad(
rust,
[
"raw_rtt_min_us",
"raw_rtt_mean_us",
"raw_rtt_median_us",
"raw_rtt_max_us",
],
)
+ " |"
)
lines.append(
"| adjusted | "
+ metric_quad(
rust,
[
"adjusted_rtt_min_us",
"adjusted_rtt_mean_us",
"adjusted_rtt_median_us",
"adjusted_rtt_max_us",
],
)
+ " |"
)
lines.extend(["", "## Artifacts"])
for label, command in [("upstream", result["upstream"]), ("irtt-rs", result["irtt_rs"])]:
lines.append(f"- {label} stdout: `{command['stdout_path']}`")
lines.append(f"- {label} stderr: `{command['stderr_path']}`")
if upstream.get("samples_path"):
lines.append(f"- upstream samples: `{upstream['samples_path']}`")
if rust.get("samples_path"):
lines.append(f"- irtt-rs samples: `{rust['samples_path']}`")
for side, metrics in [("upstream", upstream), ("irtt-rs", rust)]:
if metrics.get("parse_notes"):
lines.extend(["", f"## {side} Parse Notes"])
for note in metrics["parse_notes"]:
lines.append(f"- {note}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def summary_row(name: str, exit_code: int, metrics: dict[str, Any]) -> str:
rtt = metric_quad(metrics, ["rtt_min_us", "rtt_mean_us", "rtt_median_us", "rtt_max_us"])
return (
f"| {name} | {exit_code} | {format_metric(metrics.get('sent'))} | "
f"{format_metric(metrics.get('received'))} | "
f"{format_metric(metrics.get('loss_percent'))} | {rtt} |"
)
def metric_quad(metrics: dict[str, Any], keys: list[str]) -> str:
return "/".join(format_metric(metrics.get(key)) for key in keys)
def format_metric(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, float):
return f"{value:.3f}"
return str(value)
def format_optional_float(value: Any) -> str:
if value is None:
return "unavailable"
return f"{float(value):.3f}"
def write_index(run_dir: Path, results: list[dict[str, Any]], local_error: str | None) -> None:
lines = [
"# IRTT Client Comparison Run",
"",
f"created_at: `{utc_now()}`",
"",
"| case | target | upstream exit | irtt-rs exit | notes |",
"| --- | --- | ---: | ---: | --- |",
]
for result in results:
notes = "; ".join(result["comparison"]["notes"])
lines.append(
f"| [{result['case']['name']}]({result['case']['name']}/summary.md) | "
f"`{result['case']['target']}` | {result['upstream']['returncode']} | "
f"{result['irtt_rs']['returncode']} | {notes} |"
)
if local_error:
lines.extend(["", "## Local Server Required", "", local_error])
(run_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
write_json(run_dir / "comparison.json", {"results": results, "local_error": local_error})
def main() -> int:
args = parse_args()
try:
run_parser_self_check()
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 2
run_stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir = Path(args.output_dir) / run_stamp
run_dir.mkdir(parents=True, exist_ok=True)
cases = build_cases(args)
if not cases:
print("no test cases selected", file=sys.stderr)
return 2
local_error: str | None = None
if any(case.is_local for case in cases):
ok, message = check_local_server(args)
if not ok:
local_error = message
print(message, file=sys.stderr)
cases = [case for case in cases if not case.is_local]
if cases and not args.skip_build and not preflight_build_irtt_client(run_dir):
write_index(run_dir, [], local_error)
return 1
results: list[dict[str, Any]] = []
all_ok = local_error is None
for case in cases:
result, ok = run_case(case, run_dir, args)
results.append(result)
all_ok = all_ok and ok
write_index(run_dir, results, local_error)
print(f"[{utc_now()}] wrote comparison artifacts to {run_dir}", flush=True)
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())