1244 lines
41 KiB
Python
Executable File
1244 lines
41 KiB
Python
Executable File
#!/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<sent>\d+)\s+packets transmitted,\s+"
|
|
r"(?P<received>\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())
|