#!/usr/bin/env python3 """Measure latency, jitter, throughput, packet loss, CPU, and interface counters. The script is intended for thesis experiments such as: direct link bridge only bridge + AF_PACKET capture bridge + tc/eBPF telemetry bridge + nftables bridge + NFQUEUE script It uses only the Python standard library and common Linux tools. Raw command outputs and machine-readable summaries are written to the output directory. """ from __future__ import annotations import argparse import csv import json import math import os import platform import re import shutil import signal import socket import statistics import subprocess import sys import threading import time from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any PING_TIME_RE = re.compile(r"time[=<]([0-9.]+)\s*ms") PING_SENT_RE = re.compile( r"(?P\d+)\s+packets transmitted,\s+" r"(?P\d+)\s+(?:packets )?received" ) def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def has_cmd(name: str) -> bool: return shutil.which(name) is not None def safe_name(value: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") or "run" def format_command(command: list[str]) -> str: return " ".join(command) def read_text_file(path: Path, default: str | None = None) -> str | None: try: return path.read_text(encoding="utf-8").strip() except OSError: return default def percentile(values: list[float], pct: float) -> float | None: if not values: return None ordered = sorted(values) if len(ordered) == 1: return ordered[0] rank = (len(ordered) - 1) * pct lower = math.floor(rank) upper = math.ceil(rank) if lower == upper: return ordered[lower] return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower) def numeric_stats(values: list[float]) -> dict[str, Any]: if not values: return { "count": 0, "min": None, "max": None, "mean": None, "median": None, "stdev": None, "mad": None, "iqr": None, "cv_percent": None, "p90": None, "p95": None, "p99": None, } median = statistics.median(values) q1 = percentile(values, 0.25) q3 = percentile(values, 0.75) mean = statistics.fmean(values) stdev = statistics.stdev(values) if len(values) >= 2 else 0.0 return { "count": len(values), "min": min(values), "max": max(values), "mean": mean, "median": median, "stdev": stdev, "mad": statistics.median([abs(value - median) for value in values]), "iqr": (q3 - q1) if q1 is not None and q3 is not None else None, "cv_percent": (100.0 * stdev / mean) if mean else None, "p90": percentile(values, 0.90), "p95": percentile(values, 0.95), "p99": percentile(values, 0.99), } def jitter_stats_ms(rtts: list[float]) -> dict[str, Any]: if len(rtts) < 2: return {"mean_abs_delta_ms": None, "max_abs_delta_ms": None} deltas = [abs(rtts[i] - rtts[i - 1]) for i in range(1, len(rtts))] return { "mean_abs_delta_ms": statistics.fmean(deltas), "max_abs_delta_ms": max(deltas), "delta_stats_ms": numeric_stats(deltas), } def read_proc_stat() -> tuple[int, int]: with open("/proc/stat", "r", encoding="utf-8") as fh: parts = fh.readline().split() values = [int(value) for value in parts[1:]] idle = values[3] + (values[4] if len(values) > 4 else 0) total = sum(values) return total, idle def cpu_percent(prev: tuple[int, int], cur: tuple[int, int]) -> float: total_delta = cur[0] - prev[0] idle_delta = cur[1] - prev[1] if total_delta <= 0: return 0.0 return 100.0 * (1.0 - (idle_delta / total_delta)) def read_iface_counters(iface: str | None) -> dict[str, int]: if not iface: return {} path = Path("/sys/class/net") / iface / "statistics" if not path.is_dir(): return {} counters: dict[str, int] = {} for name in ( "rx_packets", "tx_packets", "rx_bytes", "tx_bytes", "rx_dropped", "tx_dropped", "rx_errors", "tx_errors", ): try: counters[name] = int((path / name).read_text(encoding="utf-8").strip()) except OSError: counters[name] = 0 return counters def counter_delta(before: dict[str, int], after: dict[str, int]) -> dict[str, int]: keys = set(before) | set(after) return {key: after.get(key, 0) - before.get(key, 0) for key in sorted(keys)} @dataclass class Sample: ts: float cpu_percent: float | None iface_counters: dict[str, int] def to_dict(self) -> dict[str, Any]: return { "timestamp": self.ts, "cpu_percent": self.cpu_percent, "iface_counters": self.iface_counters, } @dataclass class SystemSampler: iface: str | None interval: float = 0.5 samples: list[Sample] = field(default_factory=list) _stop: threading.Event = field(default_factory=threading.Event) _thread: threading.Thread | None = None def start(self) -> None: self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() def stop(self) -> None: self._stop.set() if self._thread is not None: self._thread.join(timeout=2.0) def _run(self) -> None: previous_cpu = read_proc_stat() while not self._stop.wait(self.interval): current_cpu = read_proc_stat() self.samples.append( Sample( ts=time.time(), cpu_percent=cpu_percent(previous_cpu, current_cpu), iface_counters=read_iface_counters(self.iface), ) ) previous_cpu = current_cpu def summary(self) -> dict[str, Any]: cpu_values = [ sample.cpu_percent for sample in self.samples if sample.cpu_percent is not None ] return { "sample_count": len(self.samples), "cpu_percent": numeric_stats(cpu_values), } def write_csv(self, path: Path) -> None: counter_names: list[str] = sorted( { name for sample in self.samples for name in sample.iface_counters } ) fieldnames = ["timestamp", "cpu_percent", *counter_names] with path.open("w", newline="", encoding="utf-8") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames) writer.writeheader() for sample in self.samples: row: dict[str, Any] = { "timestamp": sample.ts, "cpu_percent": sample.cpu_percent, } row.update(sample.iface_counters) writer.writerow(row) @dataclass class CommandRun: args: list[str] returncode: int | None stdout: str stderr: str timed_out: bool = False error: str | None = None def run_command( command: list[str], *, cwd: Path, raw_name: str, iface: str | None, timeout: float | None, ) -> tuple[CommandRun, dict[str, Any]]: before = read_iface_counters(iface) sampler = SystemSampler(iface=iface) start_monotonic = time.perf_counter() start_wall = now_iso() sampler.start() process: subprocess.Popen[str] | None = None try: process = subprocess.Popen( command, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, ) stdout, stderr = process.communicate(timeout=timeout) result = CommandRun( args=command, returncode=process.returncode, stdout=stdout or "", stderr=stderr or "", ) except subprocess.TimeoutExpired as exc: if process is not None: terminate_process_group(process) stdout, stderr = process.communicate() else: stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") result = CommandRun( args=command, returncode=process.returncode if process is not None else None, stdout=stdout, stderr=stderr, timed_out=True, error=f"command timed out after {timeout} seconds", ) except KeyboardInterrupt: if process is not None: terminate_process_group(process) raise except OSError as exc: result = CommandRun( args=command, returncode=None, stdout="", stderr=str(exc), error=str(exc), ) finally: sampler.stop() end_monotonic = time.perf_counter() after = read_iface_counters(iface) raw_path = cwd / raw_name samples_path = cwd / f"{Path(raw_name).stem}.system_samples.csv" sampler.write_csv(samples_path) raw_path.write_text( "$ " + format_command(command) + "\n\n" + "STDOUT\n" + (result.stdout or "") + "\nSTDERR\n" + (result.stderr or ""), encoding="utf-8", ) meta = { "command": command, "returncode": result.returncode, "timed_out": result.timed_out, "error": result.error, "start_time": start_wall, "duration_seconds": end_monotonic - start_monotonic, "interface_counters_before": before, "interface_counters_after": after, "interface_counters_delta": counter_delta(before, after), "system": sampler.summary(), "raw_output": str(raw_path), "system_samples_csv": str(samples_path), } (cwd / f"{Path(raw_name).stem}.command.json").write_text( json.dumps(meta, indent=2), encoding="utf-8", ) return result, meta def terminate_process_group(process: subprocess.Popen[str], grace_seconds: float = 2.0) -> None: """Terminate a command and any children it spawned.""" if process.poll() is not None: return try: os.killpg(process.pid, signal.SIGTERM) process.wait(timeout=grace_seconds) except subprocess.TimeoutExpired: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass except ProcessLookupError: pass def parse_ping_output(text: str, sent_fallback: int) -> dict[str, Any]: rtts: list[float] = [] for line in text.splitlines(): match = PING_TIME_RE.search(line) if match: rtts.append(float(match.group(1))) sent = sent_fallback received = len(rtts) for line in text.splitlines(): match = PING_SENT_RE.search(line) if match: sent = int(match.group("sent")) received = int(match.group("received")) break loss_percent = None if sent > 0: loss_percent = 100.0 * (sent - received) / sent return { "sent": sent, "received": received, "lost": max(sent - received, 0), "loss_percent": loss_percent, "rtt_ms": numeric_stats(rtts), "jitter": jitter_stats_ms(rtts), "samples_ms": rtts, } def run_ping_tests(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]: if args.skip_ping: return [] if not has_cmd("ping"): return [{"type": "ping", "skipped": True, "reason": "ping command not found"}] results: list[dict[str, Any]] = [] for size in args.payload_sizes: label = f"ping_{size}B" command = [ "ping", "-n", "-D", "-c", str(args.ping_count), "-i", str(args.ping_interval), "-s", str(size), args.target, ] if args.interface: command[1:1] = ["-I", args.interface] timeout = args.ping_count * args.ping_interval + args.ping_timeout_extra result, meta = run_command( command, cwd=out_dir, raw_name=f"{label}.txt", iface=args.interface, timeout=timeout, ) parsed = parse_ping_output(result.stdout + "\n" + result.stderr, args.ping_count) if result.returncode != 0 and not parsed["samples_ms"]: parsed["error"] = (result.stderr or result.stdout or "ping failed").strip() parsed.update( { "type": "ping", "payload_size_bytes": size, "meta": meta, } ) results.append(parsed) return results def iperf_bits_per_second(payload: dict[str, Any]) -> float | None: try: end = payload.get("end", {}) if "sum_received" in end: return float(end["sum_received"].get("bits_per_second")) if "sum" in end: return float(end["sum"].get("bits_per_second")) except (TypeError, ValueError): return None return None def run_iperf_tcp(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_iperf: return {"type": "iperf_tcp", "skipped": True, "reason": "--skip-iperf"} if not has_cmd("iperf3"): return {"type": "iperf_tcp", "skipped": True, "reason": "iperf3 command not found"} command = [ "iperf3", "-c", args.target, "-p", str(args.iperf_tcp_port), "-t", str(args.iperf_duration), "-J", ] if args.iperf_parallel > 1: command.extend(["-P", str(args.iperf_parallel)]) result, meta = run_command( command, cwd=out_dir, raw_name="iperf_tcp.txt", iface=args.interface, timeout=args.iperf_duration + args.command_timeout_extra, ) try: payload = json.loads(result.stdout) except json.JSONDecodeError: payload = {} bps = iperf_bits_per_second(payload) retransmits = None try: retransmits = payload["end"]["sum_sent"].get("retransmits") except (KeyError, TypeError): pass return { "type": "iperf_tcp", "direction": "forward", "bits_per_second": bps, "mbit_per_second": (bps / 1_000_000.0) if bps is not None else None, "retransmits": retransmits, "json": payload, "meta": meta, } def run_iperf_tcp_reverse(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_iperf or args.skip_iperf_reverse: return {"type": "iperf_tcp", "direction": "reverse", "skipped": True, "reason": "disabled"} if not has_cmd("iperf3"): return {"type": "iperf_tcp", "direction": "reverse", "skipped": True, "reason": "iperf3 command not found"} command = [ "iperf3", "-c", args.target, "-p", str(args.iperf_tcp_port), "-t", str(args.iperf_duration), "-R", "-J", ] if args.iperf_parallel > 1: command.extend(["-P", str(args.iperf_parallel)]) result, meta = run_command( command, cwd=out_dir, raw_name="iperf_tcp_reverse.txt", iface=args.interface, timeout=args.iperf_duration + args.command_timeout_extra, ) try: payload = json.loads(result.stdout) except json.JSONDecodeError: payload = {} bps = iperf_bits_per_second(payload) retransmits = None try: retransmits = payload["end"]["sum_sent"].get("retransmits") except (KeyError, TypeError): pass return { "type": "iperf_tcp", "direction": "reverse", "bits_per_second": bps, "mbit_per_second": (bps / 1_000_000.0) if bps is not None else None, "retransmits": retransmits, "json": payload, "meta": meta, } def run_iperf_udp(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_iperf or args.skip_udp_iperf: return {"type": "iperf_udp", "skipped": True, "reason": "disabled"} if not has_cmd("iperf3"): return {"type": "iperf_udp", "skipped": True, "reason": "iperf3 command not found"} command = [ "iperf3", "-c", args.target, "-p", str(args.iperf_udp_port), "-t", str(args.iperf_duration), "-u", "-b", args.udp_bandwidth, "-J", ] result, meta = run_command( command, cwd=out_dir, raw_name="iperf_udp.txt", iface=args.interface, timeout=args.iperf_duration + args.command_timeout_extra, ) try: payload = json.loads(result.stdout) except json.JSONDecodeError: payload = {} end = payload.get("end", {}) if isinstance(payload, dict) else {} udp_sum = end.get("sum", {}) if isinstance(end, dict) else {} bps = udp_sum.get("bits_per_second") try: bps = float(bps) if bps is not None else None except (TypeError, ValueError): bps = None return { "type": "iperf_udp", "direction": "forward", "bits_per_second": bps, "mbit_per_second": (bps / 1_000_000.0) if bps is not None else None, "jitter_ms": udp_sum.get("jitter_ms"), "lost_packets": udp_sum.get("lost_packets"), "packets": udp_sum.get("packets"), "lost_percent": udp_sum.get("lost_percent"), "json": payload, "meta": meta, } def run_iperf_udp_reverse(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_iperf or args.skip_udp_iperf or args.skip_iperf_reverse: return {"type": "iperf_udp", "direction": "reverse", "skipped": True, "reason": "disabled"} if not has_cmd("iperf3"): return {"type": "iperf_udp", "direction": "reverse", "skipped": True, "reason": "iperf3 command not found"} command = [ "iperf3", "-c", args.target, "-p", str(args.iperf_udp_port), "-t", str(args.iperf_duration), "-u", "-b", args.udp_bandwidth, "-R", "-J", ] result, meta = run_command( command, cwd=out_dir, raw_name="iperf_udp_reverse.txt", iface=args.interface, timeout=args.iperf_duration + args.command_timeout_extra, ) try: payload = json.loads(result.stdout) except json.JSONDecodeError: payload = {} end = payload.get("end", {}) if isinstance(payload, dict) else {} udp_sum = end.get("sum", {}) if isinstance(end, dict) else {} bps = udp_sum.get("bits_per_second") try: bps = float(bps) if bps is not None else None except (TypeError, ValueError): bps = None return { "type": "iperf_udp", "direction": "reverse", "bits_per_second": bps, "mbit_per_second": (bps / 1_000_000.0) if bps is not None else None, "jitter_ms": udp_sum.get("jitter_ms"), "lost_packets": udp_sum.get("lost_packets"), "packets": udp_sum.get("packets"), "lost_percent": udp_sum.get("lost_percent"), "json": payload, "meta": meta, } def run_arping(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_arping: return {"type": "arping", "skipped": True, "reason": "--skip-arping"} if not args.interface: return {"type": "arping", "skipped": True, "reason": "requires --interface"} if not has_cmd("arping"): return {"type": "arping", "skipped": True, "reason": "arping command not found"} command = ["arping", "-I", args.interface, "-c", str(args.arping_count), args.target] result, meta = run_command( command, cwd=out_dir, raw_name="arping.txt", iface=args.interface, timeout=args.arping_count + args.command_timeout_extra, ) samples: list[float] = [] for match in re.finditer(r"time[=\s]([0-9.]+)\s*ms", result.stdout + "\n" + result.stderr): samples.append(float(match.group(1))) return { "type": "arping", "samples_ms": samples, "rtt_ms": numeric_stats(samples), "jitter": jitter_stats_ms(samples), "meta": meta, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } def run_traceroute(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_path_tools: return {"type": "traceroute", "skipped": True, "reason": "--skip-path-tools"} if not has_cmd("traceroute"): return {"type": "traceroute", "skipped": True, "reason": "traceroute command not found"} command = ["traceroute", "-n", "-q", "3", args.target] result, meta = run_command( command, cwd=out_dir, raw_name="traceroute.txt", iface=args.interface, timeout=args.command_timeout_extra + 60, ) return { "type": "traceroute", "meta": meta, "output": result.stdout, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } def run_mtr(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: if args.skip_path_tools: return {"type": "mtr", "skipped": True, "reason": "--skip-path-tools"} if not has_cmd("mtr"): return {"type": "mtr", "skipped": True, "reason": "mtr command not found"} command = ["mtr", "-n", "-r", "-c", str(args.mtr_count), "--json", args.target] result, meta = run_command( command, cwd=out_dir, raw_name="mtr.json.txt", iface=args.interface, timeout=args.mtr_count + args.command_timeout_extra + 20, ) try: payload = json.loads(result.stdout) except json.JSONDecodeError: payload = {} return { "type": "mtr", "json": payload, "meta": meta, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } def run_sockperf(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]: if args.skip_sockperf: return [{"type": "sockperf", "skipped": True, "reason": "--skip-sockperf"}] if not has_cmd("sockperf"): return [{"type": "sockperf", "skipped": True, "reason": "sockperf command not found"}] results: list[dict[str, Any]] = [] for protocol in args.sockperf_protocols: raw_name = f"sockperf_{safe_name(protocol)}.txt" port = args.sockperf_tcp_port if protocol == "tcp" else args.sockperf_udp_port command = [ "sockperf", "ping-pong", "-i", args.target, "--time", str(args.sockperf_duration), "--port", str(port), ] if protocol == "tcp": command.append("--tcp") elif protocol == "udp": command.append("--udp") result, meta = run_command( command, cwd=out_dir, raw_name=raw_name, iface=args.interface, timeout=args.sockperf_duration + args.command_timeout_extra, ) text = result.stdout + "\n" + result.stderr extracted: dict[str, float] = {} for key, pattern in { "avg_latency_usec": r"avg-latency[=\s:]+([0-9.]+)", "min_latency_usec": r"min-latency[=\s:]+([0-9.]+)", "max_latency_usec": r"max-latency[=\s:]+([0-9.]+)", }.items(): match = re.search(pattern, text, flags=re.IGNORECASE) if match: extracted[key] = float(match.group(1)) results.append( { "type": "sockperf", "protocol": protocol, "port": port, "extracted": extracted, "meta": meta, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } ) return results def run_flent(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]: if args.skip_flent: return [{"type": "flent", "skipped": True, "reason": "--skip-flent"}] if not has_cmd("flent"): return [{"type": "flent", "skipped": True, "reason": "flent command not found"}] results: list[dict[str, Any]] = [] for test_name in args.flent_tests: data_file = out_dir / f"flent_{safe_name(test_name)}.flent.gz" raw_name = f"flent_{safe_name(test_name)}.txt" command = [ "flent", test_name, "-l", str(args.flent_duration), "-H", args.target, "-t", f"{args.label}-{test_name}", "-o", str(data_file), ] result, meta = run_command( command, cwd=out_dir, raw_name=raw_name, iface=args.interface, timeout=args.flent_duration + args.command_timeout_extra + 60, ) results.append( { "type": "flent", "test": test_name, "data_file": str(data_file), "meta": meta, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } ) return results def run_metadata_command(command: list[str], out_dir: Path, name: str, timeout: float = 10.0) -> dict[str, Any]: if not command or not has_cmd(command[0]): return {"name": name, "command": command, "skipped": True, "reason": f"{command[0] if command else 'command'} not found"} result, meta = run_command( command, cwd=out_dir, raw_name=f"env_{safe_name(name)}.txt", iface=None, timeout=timeout, ) return { "name": name, "command": command, "returncode": result.returncode, "meta": meta, "error": result.stderr.strip() if result.returncode not in (0, None) else None, } def collect_environment(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]: env_dir = out_dir / "environment" env_dir.mkdir(exist_ok=True) iface = args.interface sysfs: dict[str, Any] = {} if iface: base = Path("/sys/class/net") / iface for name in ("address", "operstate", "mtu", "speed", "duplex", "carrier", "flags"): sysfs[name] = read_text_file(base / name) sysfs["statistics"] = read_iface_counters(iface) proc_files: dict[str, str | None] = {} for proc_name in ("net/dev", "net/softnet_stat", "interrupts", "softirqs"): source = Path("/proc") / proc_name content = read_text_file(source) proc_files[proc_name] = str(env_dir / f"proc_{safe_name(proc_name)}.txt") if content is not None else None if content is not None: (env_dir / f"proc_{safe_name(proc_name)}.txt").write_text(content + "\n", encoding="utf-8") commands: list[tuple[str, list[str]]] = [ ("uname", ["uname", "-a"]), ("ip_addr", ["ip", "addr", "show"]), ("ip_route", ["ip", "route", "show", "table", "all"]), ("ip_neigh", ["ip", "neigh", "show"]), ("tc_qdisc", ["tc", "qdisc", "show"]), ("nft_ruleset", ["nft", "list", "ruleset"]), ] if iface: commands.extend( [ ("ip_link_iface", ["ip", "-details", "link", "show", "dev", iface]), ("tc_qdisc_iface", ["tc", "qdisc", "show", "dev", iface]), ("tc_filter_ingress_iface", ["tc", "filter", "show", "dev", iface, "ingress"]), ("tc_filter_egress_iface", ["tc", "filter", "show", "dev", iface, "egress"]), ("ethtool_iface", ["ethtool", iface]), ("ethtool_offloads_iface", ["ethtool", "-k", iface]), ("ethtool_stats_iface", ["ethtool", "-S", iface]), ] ) command_results = [ run_metadata_command(command, env_dir, name) for name, command in commands ] environment = { "host": { "hostname": socket.gethostname(), "platform": platform.platform(), "python": sys.version, "kernel": platform.release(), }, "target": args.target, "interface": iface, "interface_sysfs": sysfs, "proc_snapshots": proc_files, "tool_availability": { name: has_cmd(name) for name in ( "ping", "arping", "iperf3", "flent", "sockperf", "mtr", "traceroute", "ip", "tc", "nft", "ethtool", ) }, "commands": command_results, } (env_dir / "environment.json").write_text(json.dumps(environment, indent=2), encoding="utf-8") return environment def write_ping_csv(results: list[dict[str, Any]], out_dir: Path) -> None: path = out_dir / "ping_samples.csv" with path.open("w", newline="", encoding="utf-8") as fh: writer = csv.DictWriter(fh, fieldnames=["payload_size_bytes", "sample_index", "rtt_ms"]) writer.writeheader() for result in results: if result.get("type") != "ping": continue for idx, rtt in enumerate(result.get("samples_ms", []), start=1): writer.writerow( { "payload_size_bytes": result.get("payload_size_bytes"), "sample_index": idx, "rtt_ms": rtt, } ) def write_summary_md(summary: dict[str, Any], out_dir: Path) -> None: lines = [ "# Network timing benchmark", "", f"- Label: `{summary['label']}`", f"- Target: `{summary['target']}`", f"- Interface: `{summary.get('interface') or 'default route'}`", f"- Started: `{summary['started_at']}`", "", "## Ping", "", "| Payload | Sent | Received | Loss % | Mean RTT ms | Median | p95 | p99 | Mean jitter ms |", "|---:|---:|---:|---:|---:|---:|---:|---:|---:|", ] for result in summary["tests"]: if result.get("type") != "ping" or result.get("skipped"): continue if result.get("error"): lines.append( f"| {result.get('payload_size_bytes')} | {result.get('sent')} | " f"{result.get('received')} | {result.get('loss_percent') or 0.0:.3f} | " "0.000 | 0.000 | 0.000 | 0.000 | 0.000 |" ) lines.append("") lines.append(f"Ping error for payload {result.get('payload_size_bytes')} B: `{result['error']}`") lines.append("") continue rtt = result.get("rtt_ms", {}) jitter = result.get("jitter", {}) lines.append( "| {payload} | {sent} | {received} | {loss:.3f} | {mean:.3f} | " "{median:.3f} | {p95:.3f} | {p99:.3f} | {jitter:.3f} |".format( payload=result.get("payload_size_bytes"), sent=result.get("sent"), received=result.get("received"), loss=result.get("loss_percent") or 0.0, mean=rtt.get("mean") or 0.0, median=rtt.get("median") or 0.0, p95=rtt.get("p95") or 0.0, p99=rtt.get("p99") or 0.0, jitter=jitter.get("mean_abs_delta_ms") or 0.0, ) ) lines.extend(["", "## Throughput", ""]) for result in summary["tests"]: if result.get("type") not in {"iperf_tcp", "iperf_udp"}: continue if result.get("skipped"): direction = result.get("direction") direction_text = f" `{direction}`" if direction else "" lines.append(f"- `{result['type']}`{direction_text} skipped: {result.get('reason')}") continue lines.append( f"- `{result['type']}` `{result.get('direction', 'forward')}`: {result.get('mbit_per_second')} Mbit/s" + ( f", loss={result.get('lost_percent')}%, jitter={result.get('jitter_ms')} ms" if result["type"] == "iperf_udp" else f", retransmits={result.get('retransmits')}" ) ) lines.extend(["", "## Additional Tests", ""]) for result in summary["tests"]: result_type = result.get("type") if result_type in {"ping", "iperf_tcp", "iperf_udp"}: continue if result.get("skipped"): lines.append(f"- `{result_type}` skipped: {result.get('reason')}") continue if result_type == "arping": stats = result.get("rtt_ms", {}) lines.append( f"- `arping`: count={stats.get('count')}, mean={stats.get('mean')} ms, " f"p95={stats.get('p95')} ms" ) elif result_type == "sockperf": lines.append(f"- `sockperf` `{result.get('protocol')}`: {result.get('extracted')}") elif result_type == "flent": lines.append(f"- `flent` `{result.get('test')}`: data={result.get('data_file')}") else: lines.append(f"- `{result_type}`: raw={result.get('meta', {}).get('raw_output')}") lines.extend(["", "## Raw Data", ""]) lines.append("- Full structured data: `summary.json`") lines.append("- Ping samples: `ping_samples.csv`") lines.append("- Environment snapshot: `environment/environment.json`") lines.append("- Per-command raw output: `*.txt` files beside `*.command.json` metadata") (out_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") def run_test_suite(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]: tests: list[dict[str, Any]] = [] tests.extend(run_ping_tests(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_arping(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_iperf_tcp(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_iperf_tcp_reverse(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_iperf_udp(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_iperf_udp_reverse(args, out_dir)) cooldown(args.cooldown_seconds) tests.extend(run_sockperf(args, out_dir)) cooldown(args.cooldown_seconds) tests.extend(run_flent(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_traceroute(args, out_dir)) cooldown(args.cooldown_seconds) tests.append(run_mtr(args, out_dir)) return tests def write_run_outputs( args: argparse.Namespace, environment: dict[str, Any], tests: list[dict[str, Any]], out_dir: Path, ) -> dict[str, Any]: summary = { "label": args.label, "target": args.target, "interface": args.interface, "started_at": now_iso(), "arguments": vars(args), "environment": environment, "tests": tests, } (out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") write_ping_csv([test for test in tests if test.get("type") == "ping"], out_dir) write_summary_md(summary, out_dir) return summary def parse_payload_sizes(value: str) -> list[int]: try: sizes = [int(item.strip()) for item in value.split(",") if item.strip()] except ValueError as exc: raise argparse.ArgumentTypeError("payload sizes must be comma-separated integers") from exc if not sizes or any(size < 0 for size in sizes): raise argparse.ArgumentTypeError("payload sizes must contain non-negative integers") return sizes def parse_csv_strings(value: str) -> list[str]: items = [item.strip() for item in value.split(",") if item.strip()] if not items: raise argparse.ArgumentTypeError("value must contain at least one item") return items def cooldown(seconds: float) -> None: if seconds <= 0: return time.sleep(seconds) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Measure latency, jitter, throughput, loss, CPU, and interface counters.", ) parser.add_argument("--target", required=True, help="Target host or IP address.") parser.add_argument("--interface", "-i", help="Interface used for source binding/counters.") parser.add_argument("--label", default="experiment", help="Experiment label, e.g. bridge-ebpf.") parser.add_argument("--out-dir", default="measurements", help="Base output directory.") parser.add_argument( "--packet-count", type=int, help=( "Convenience packet/probe count for packet-count based tests. " "Used for ping, arping, and mtr unless their specific count option is set." ), ) parser.add_argument( "--duration", type=int, help=( "Convenience duration in seconds for duration-based tests. " "Used for iperf3, sockperf, and flent unless their specific duration option is set." ), ) parser.add_argument("--payload-sizes", type=parse_payload_sizes, default=[56, 512, 1472]) parser.add_argument("--ping-count", type=int, default=5000) parser.add_argument("--ping-interval", type=float, default=0.005) parser.add_argument("--ping-timeout-extra", type=float, default=20.0) parser.add_argument("--skip-ping", action="store_true") parser.add_argument("--skip-arping", action="store_true") parser.add_argument("--arping-count", type=int) parser.add_argument("--skip-iperf", action="store_true") parser.add_argument("--skip-udp-iperf", action="store_true") parser.add_argument("--skip-iperf-reverse", action="store_true") parser.add_argument("--iperf-tcp-port", type=int, default=5201) parser.add_argument("--iperf-udp-port", type=int, default=5202) parser.add_argument("--iperf-duration", type=int) parser.add_argument("--iperf-parallel", type=int, default=1) parser.add_argument("--udp-bandwidth", default="900M") parser.add_argument("--skip-flent", action="store_true") parser.add_argument("--flent-tests", type=parse_csv_strings, default=["rrul", "tcp_upload", "tcp_download"]) parser.add_argument("--flent-duration", type=int) parser.add_argument("--skip-sockperf", action="store_true") parser.add_argument("--sockperf-protocols", type=parse_csv_strings, default=["udp", "tcp"]) parser.add_argument("--sockperf-tcp-port", type=int, default=11111) parser.add_argument("--sockperf-udp-port", type=int, default=11112) parser.add_argument("--sockperf-duration", type=int) parser.add_argument("--skip-path-tools", action="store_true") parser.add_argument("--mtr-count", type=int) parser.add_argument("--no-archive", action="store_true") parser.add_argument("--command-timeout-extra", type=float, default=15.0) parser.add_argument( "--cooldown-seconds", type=float, default=10.0, help="Sleep after each test so local queues, CPU, and the backend can drain before the next test.", ) return parser def main() -> int: parser = build_parser() args = parser.parse_args() if args.packet_count is not None and args.packet_count < 1: parser.error("--packet-count must be >= 1") if args.duration is not None and args.duration < 1: parser.error("--duration must be >= 1") if args.ping_count is None: args.ping_count = args.packet_count if args.packet_count is not None else 1000 if args.arping_count is None: args.arping_count = args.packet_count if args.packet_count is not None else 100 if args.mtr_count is None: args.mtr_count = args.packet_count if args.packet_count is not None else 100 if args.iperf_duration is None: args.iperf_duration = args.duration if args.duration is not None else 20 if args.flent_duration is None: args.flent_duration = args.duration if args.duration is not None else 60 if args.sockperf_duration is None: args.sockperf_duration = args.duration if args.duration is not None else 30 if args.ping_count < 1: parser.error("--ping-count must be >= 1") if args.ping_interval <= 0: parser.error("--ping-interval must be > 0") if args.iperf_duration < 1: parser.error("--iperf-duration must be >= 1") if args.flent_duration < 1: parser.error("--flent-duration must be >= 1") if args.sockperf_duration < 1: parser.error("--sockperf-duration must be >= 1") if args.cooldown_seconds < 0: parser.error("--cooldown-seconds must be >= 0") for protocol in args.sockperf_protocols: if protocol not in {"tcp", "udp"}: parser.error("--sockperf-protocols may only contain tcp and/or udp") timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") out_dir = Path(args.out_dir) / f"{timestamp}-{args.label}" out_dir.mkdir(parents=True, exist_ok=True) environment = collect_environment(args, out_dir) tests = run_test_suite(args, out_dir) write_run_outputs(args, environment, tests, out_dir) archive_path = None if not args.no_archive: archive_path = shutil.make_archive(str(out_dir), "gztar", root_dir=out_dir) print(f"Wrote benchmark results to {out_dir}") print(f"Summary: {out_dir / 'summary.md'}") if archive_path: print(f"Archive: {archive_path}") return 0 if __name__ == "__main__": raise SystemExit(main())