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.
This commit is contained in:
Nils Andreas Svee committed 2026-08-20 11:44:35 +07:00
1 parent 7129ee0bc5
commit 7bea3dd54e
49 files changed
+365 -213

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
[package]
name = "irtt-rs"
version = "0.6.0"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
name = "irtt_app"
doc = false
[[bin]]
name = "irtt-rs"
path = "src/bin/irtt-rs.rs"
[[bin]]
name = "irtt-client"
path = "src/bin/irtt-client.rs"
required-features = ["client"]
# This applet shares its name with the irtt-client library crate, whose rustdoc
# output it would otherwise collide with.
doc = false
[[bin]]
name = "irtt-tui"
path = "src/bin/irtt-tui.rs"
required-features = ["tui"]
[[bin]]
name = "irtt-server"
path = "src/bin/irtt-server.rs"
required-features = ["server"]
# This applet shares its name with the irtt-server library crate, whose rustdoc
# output it would otherwise collide with.
doc = false
[features]
default = ["client", "server", "tui"]
client = ["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
server = ["dep:irtt-server", "dep:tokio"]
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
[dependencies]
clap = { version = "4.6.6", features = ["derive"] }
crossterm = { version = "0.29.0", optional = true }
ctrlc = "3.5.2"
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-server = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.30.2", optional = true }
tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"], optional = true }
[dev-dependencies]
irtt-server.workspace = true
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
+326
View File
@@ -0,0 +1,326 @@
use std::{ffi::OsString, path::Path};
use clap::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppletDispatch {
Run {
applet: RequestedApplet,
argv: Vec<OsString>,
},
Help(String),
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
if let Some(applet) = detect_applet_from_argv0(argv0) {
return Ok(AppletDispatch::Run { applet, argv });
}
let basename = applet_basename(argv0);
if basename.starts_with("irtt-") && basename != "irtt-rs" {
return Err(format!(
"unknown applet name '{basename}'. Known applet names: {}",
known_applet_binary_names()
));
}
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
return Err(format!(
"choose an applet: {}\n\n{}",
applet_command_names(),
dispatcher_help()
));
};
match command {
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: applet_argv("irtt-client", &argv[2..]),
}),
"tui" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: applet_argv("irtt-tui", &argv[2..]),
}),
"server" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Server,
argv: applet_argv("irtt-server", &argv[2..]),
}),
_ => Err(format!(
"unknown applet '{command}'. Choose one of: {}\n\n{}",
applet_command_names(),
dispatcher_help()
)),
}
}
pub fn dispatcher_help() -> String {
let mut command = Command::new("irtt-rs")
.about("IRTT-compatible multi-applet dispatcher")
.subcommand(Command::new("client").about(client_applet_about()))
.subcommand(Command::new("tui").about(tui_applet_about()))
.subcommand(Command::new("server").about(server_applet_about()))
.after_help(dispatcher_after_help());
command.render_help().to_string()
}
fn client_applet_about() -> &'static str {
if cfg!(feature = "client") {
"Run the stream client applet"
} else {
"Stream client applet is not available in this build"
}
}
fn tui_applet_about() -> &'static str {
if cfg!(feature = "tui") {
"Run the terminal UI applet"
} else {
"Terminal UI applet is not available in this build"
}
}
fn server_applet_about() -> &'static str {
if cfg!(feature = "server") {
"Run the UDP server applet"
} else {
"UDP server applet is not available in this build"
}
}
fn known_applet_binary_names() -> &'static str {
"irtt-rs, irtt-client, irtt-tui, irtt-server"
}
fn applet_command_names() -> &'static str {
"client, tui, or server"
}
fn dispatcher_after_help() -> String {
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
let mut missing = Vec::new();
if !cfg!(feature = "client") {
missing.push("irtt-client requires the client feature");
}
if !cfg!(feature = "server") {
missing.push("irtt-server requires the server feature");
}
if !cfg!(feature = "tui") {
missing.push("irtt-tui requires the tui feature");
}
match missing.len() {
0 => {}
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
}
help
}
fn applet_basename(argv0: &str) -> &str {
let name = Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0);
strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX)
}
/// Removes the platform executable suffix from a file name.
///
/// The suffix is part of the file name on Windows and empty everywhere else, so
/// this is a no-op on Unix. Without it, every applet name — including the
/// `irtt-rs` dispatcher itself — reaches argv0 detection as `<name>.exe` and is
/// rejected as an unknown applet. The applet names themselves are still matched
/// exactly; only this platform artifact is case-insensitive, because it is not
/// a name the user chose.
///
/// The suffix is a parameter so that Windows behavior is assertable from any
/// host, not only where `EXE_SUFFIX` happens to be non-empty.
fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str {
if suffix.is_empty() || name.len() <= suffix.len() {
return name;
}
let split = name.len() - suffix.len();
let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else {
return name;
};
if tail.eq_ignore_ascii_case(suffix) {
stem
} else {
name
}
}
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push(OsString::from(applet_name));
argv.extend(args.iter().cloned());
argv
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-client"),
Some(RequestedApplet::Client)
);
assert_eq!(
detect_applet_from_argv0("irtt-tui"),
Some(RequestedApplet::Tui)
);
assert_eq!(
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
}
#[test]
fn applet_detection_ignores_the_platform_executable_suffix() {
let exe = std::env::consts::EXE_SUFFIX;
assert_eq!(
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
Some(RequestedApplet::Server)
);
assert_eq!(
detect_applet_from_argv0(&format!("irtt-client{exe}")),
Some(RequestedApplet::Client)
);
// The dispatcher's own name must not be taken for an unknown applet.
let dispatcher = format!("irtt-rs{exe}");
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
// A name that merely ends in the suffix letters keeps them.
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
}
#[test]
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
assert_eq!(
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
"irtt-server"
);
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
assert_eq!(
strip_suffix_ignoring_case("irtt-server", ".exe"),
"irtt-server"
);
assert_eq!(
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
"irtt-serverexe"
);
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
assert_eq!(
strip_suffix_ignoring_case("irtt-server.exe", ""),
"irtt-server.exe"
);
// A multi-byte tail must not split mid-character.
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
}
#[test]
fn canonical_help_renders_dispatcher_help() {
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
let AppletDispatch::Help(help) = dispatch else {
panic!("expected dispatcher help");
};
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
assert!(help.contains("client"));
if cfg!(feature = "tui") {
assert!(help.contains("Run the terminal UI applet"));
} else {
assert!(help.contains("Terminal UI applet is not available in this build"));
}
assert!(help.contains("server"));
if cfg!(feature = "server") {
assert!(help.contains("Run the UDP server applet"));
} else {
assert!(help.contains("UDP server applet is not available in this build"));
}
}
#[test]
fn canonical_without_applet_is_an_error() {
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
assert!(err.contains("choose an applet"));
assert!(err.contains("client"));
if cfg!(feature = "tui") {
assert!(err.contains("tui"));
}
assert!(err.contains("server"));
}
#[test]
fn canonical_subcommands_dispatch_to_applet_argv() {
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
.unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: vec!["irtt-client".into(), "host:2112".into()],
}
);
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Tui,
argv: vec!["irtt-tui".into(), "host:2112".into()],
}
);
assert_eq!(
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Server,
argv: vec!["irtt-server".into()],
}
);
}
#[test]
fn unknown_irtt_binary_name_is_an_error() {
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
assert!(err.contains("unknown applet name 'irtt-typo'"));
assert!(err.contains("irtt-client"));
if cfg!(feature = "tui") {
assert!(err.contains("irtt-tui"));
}
assert!(err.contains("irtt-server"));
}
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::client_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::dispatcher_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::server_main()
}
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_app::entry::tui_main()
}
+319
View File
@@ -0,0 +1,319 @@
use std::time::Duration;
use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection, TimestampArg,
};
use clap::{Parser, ValueEnum};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client host-a:2112 eu=host-b:2112"
)]
pub targets: Vec<TargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
default_value = "10s",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Event row output format.
#[arg(
long,
value_enum,
default_value_t = OutputFormat::Table,
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
)]
pub format: OutputFormat,
/// Comma-separated event row columns, or default/all.
#[arg(
short = 'c',
long,
value_name = "COLUMNS",
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
)]
pub columns: Option<String>,
/// List available event row columns and aliases, then exit.
#[arg(
long,
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
)]
pub list_columns: bool,
/// Header policy for table, CSV, and TSV output.
#[arg(
long,
value_enum,
default_value_t = HeaderMode::Auto,
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
)]
pub header: HeaderMode,
/// Include extra fields in table output and final summaries.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
/// Validate the selected targets and prepare the managed run.
///
/// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
},
)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
/// Readable terminal table output.
Table,
/// Comma-separated event rows.
Csv,
/// Tab-separated event rows.
Tsv,
/// One JSON object per event row.
Jsonl,
}
impl OutputFormat {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Table)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HeaderMode {
/// Print headers for table, CSV, and TSV output.
Auto,
/// Always print headers where the format supports them.
Always,
/// Never print headers.
Never,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-client"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.prepare().unwrap().client;
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
for format in ["table", "csv", "tsv", "jsonl"] {
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn list_columns_does_not_require_server() {
let args = parse(&["--list-columns"]).unwrap();
assert!(args.targets.is_empty());
assert!(args.list_columns);
// The parser accepts no target; preparation is where a target becomes
// mandatory, and the stream command returns before preparing when
// listing columns.
let err = args.prepare().unwrap_err();
assert!(err.contains("--list-columns"));
}
#[test]
fn multiple_positional_targets_parse_as_targets() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
}
#[test]
fn repeated_labeled_targets_parse() {
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
}
#[test]
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
let specs = args.prepare().unwrap().targets;
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
let err = args.prepare().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
assert_eq!(args.prepare().unwrap().target_count(), 2);
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
}
#[test]
fn old_target_option_is_rejected() {
assert!(parse(&["--target", "eu=host.example"]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--format <FORMAT>"));
assert!(!help.contains("--target "));
assert!(help.contains("--pacing <PACING>"));
assert!(help.contains("--columns <COLUMNS>"));
assert!(help.contains("--list-columns"));
assert!(help.contains("--header <HEADER>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_non_empty_hmac_key_is_accepted() {
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
let config = args.prepare().unwrap().client;
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
}
#[test]
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
assert!(!OutputFormat::Csv.prints_summary());
assert!(!OutputFormat::Tsv.prints_summary());
assert!(!OutputFormat::Jsonl.prints_summary());
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod args;
pub mod output;
pub mod run;
pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat};
pub use run::run_stream;
File diff suppressed because it is too large. Load diff
+490
View File
@@ -0,0 +1,490 @@
use std::collections::BTreeMap;
use std::{
collections::HashSet,
io::{self, Write},
sync::atomic::AtomicBool,
thread,
time::Duration,
};
use irtt_client::{
managed::{
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
use super::{
args::ClientArgs,
output::{EventRenderStats, OutputConfig},
};
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
},
};
use irtt_stats::{StatsCollector, StatsConfig};
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
if args.list_columns {
print!("{}", OutputConfig::list_columns());
return Ok(());
}
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let multi_target = setup.is_multi_target();
let output_config = OutputConfig::new(
args.format,
args.columns.as_deref(),
args.header,
args.verbose,
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous();
let target_count = setup.target_count();
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
eprintln!("{warning}");
}
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
)?;
let handle = owner.handle();
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
let mut stats = setup
.targets
.iter()
.map(|target| {
(
target.label.clone(),
StatsCollector::new(stats_config(continuous)),
)
})
.collect::<BTreeMap<_, _>>();
let mut terminal_targets = HashSet::new();
let mut dropped_events = 0_u64;
let mut interrupted = false;
let mut stop_requested = false;
let mut subscription_closed = false;
loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if request_managed_stop_for_peer_close(
continuous,
interrupted,
handle.status().peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
let drain_state = drain_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
}
if interrupted {
eprintln!("interrupted, closing managed run...");
}
let outcome = owner.join()?;
drain_final_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
if outcome.discarded_target_outcomes != 0 {
eprintln!(
"irtt-rs: warning: {} final target outcomes were discarded",
outcome.discarded_target_outcomes
);
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
report_target_failure(target);
}
}
interrupted |= is_shutdown_requested(shutdown_requested);
let terminal_error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
.or_else(|| {
(!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
}),
};
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.show_running_only_summary_note =
continuous && interrupted && stream_output.print_final_summary;
for target in &setup.targets {
let target_stats = stats.get(&target.label).expect(
"every prepared target must have a stats collector inserted before the run begins",
);
if multi_target
&& stream_output.print_final_summary
&& stream_output.config.prints_summary()
{
writeln!(stream_output.out)?;
writeln!(stream_output.out, "target: {}", target.label)?;
}
stream_output.print_summary(target_stats)?;
}
stream_output.out.flush()?;
if let Some(error) = terminal_error {
return Err(error.into());
}
Ok(())
}
fn process_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
) -> io::Result<()> {
match event {
ManagedEvent::Client { target, event } => {
let collector = stats
.entry(target.id.as_str().to_owned())
.or_insert_with(|| StatsCollector::new(stats_config(false)));
print_events_with_stats(
stream_output,
std::slice::from_ref(&event),
Some(target.id.as_str()),
collector,
)?;
}
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
report_target_failure(&outcome);
}
_ => {}
}
Ok(())
}
fn drain_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn report_target_failure(target: &ManagedTargetOutcome) {
for message in target_failure_messages(target) {
eprintln!("{message}");
}
}
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
let mut messages = Vec::with_capacity(2);
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
messages.push(format!(
"irtt-rs: target {} failed ({} {}): {}",
target.target.id, failure.phase, failure.kind, failure.message
));
}
if let Some(failure) = &target.cleanup_failure {
messages.push(format!(
"irtt-rs: target {} cleanup failed ({} {}): {}",
target.target.id, failure.phase, failure.kind, failure.message
));
}
messages
}
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" }))
}
struct StreamOutput<'a, W: Write> {
config: OutputConfig,
header_printed: bool,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(
&mut self,
events: &[ClientEvent],
target: Option<&str>,
stats_updates: &[EventRenderStats],
) -> io::Result<()> {
self.print_header()?;
for (event, stats_update) in events.iter().zip(stats_updates) {
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
writeln!(self.out, "{line}")?;
}
}
Ok(())
}
fn print_header(&mut self) -> io::Result<()> {
if self.header_printed {
return Ok(());
}
self.header_printed = true;
if let Some(header) = self.config.render_header() {
writeln!(self.out, "{header}")?;
}
Ok(())
}
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if self.print_final_summary && self.config.prints_summary() {
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.config.summary_verbose(),
show_running_only_note: self.show_running_only_summary_note
}
)
)?;
}
Ok(())
}
}
fn print_events_with_stats<W: Write>(
stream_output: &mut StreamOutput<'_, W>,
events: &[ClientEvent],
target: Option<&str>,
stats: &mut StatsCollector,
) -> io::Result<()> {
let updates = events
.iter()
.map(|event| EventRenderStats::from(stats.process(event)))
.collect::<Vec<_>>();
stream_output.print_events(events, target, &updates)
}
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
let total_probe_count =
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
// Ask the stats crate what the configuration this run will actually use
// retains; the CLI owns the probe count and the thresholds, not the
// retention model.
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = if estimated_bytes >= GIB {
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
};
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::managed::{
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
};
use std::sync::Arc;
fn long_finite_args() -> ClientArgs {
use clap::Parser;
ClientArgs::try_parse_from([
"irtt-client",
"--duration",
"4200s",
"--interval",
"100ms",
"127.0.0.1:2112",
])
.unwrap()
}
/// Smallest target count whose aggregate estimate reaches the warning
/// threshold, derived from the stats crate's own estimate rather than from
/// any bytes-per-probe assumption held here.
fn first_warning_target_count(args: &ClientArgs) -> usize {
let probes = expected_probe_count(args.duration, args.interval);
(1..=1024)
.find(|targets| {
let total = probes.saturating_mul(*targets as u64);
stats_config(false).estimated_retained_bytes(total)
>= FINITE_STATS_MEMORY_WARNING_BYTES
})
.expect("a long finite run should cross the warning threshold within 1024 targets")
}
#[test]
fn finite_stats_memory_warning_accounts_for_target_count() {
let args = long_finite_args();
let crossing = first_warning_target_count(&args);
assert!(
crossing > 1,
"this run should need more than one target to cross the threshold, \
otherwise the test proves nothing about target-count accounting"
);
assert!(
finite_stats_memory_warning(&args, crossing - 1).is_none(),
"an aggregate estimate below the threshold should not warn"
);
assert!(
finite_stats_memory_warning(&args, crossing).is_some(),
"an aggregate estimate at the threshold should warn"
);
}
#[test]
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
}
#[test]
fn lagged_events_are_counted_saturatingly() {
let mut dropped = 0_u64;
dropped = dropped.saturating_add(7);
dropped = dropped.saturating_add(3);
assert_eq!(dropped, 10);
assert_eq!(u64::MAX.saturating_add(1), u64::MAX);
assert!(dropped_event_warning(dropped)
.unwrap()
.contains("dropped 10 managed run events"));
}
#[test]
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
let target = ManagedTargetOutcome {
target: TargetInstance {
id: TargetId::from("edge"),
generation: 1,
},
server_addr: Arc::from("127.0.0.1:2112"),
remote: None,
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Receiving,
kind: ManagedTargetFailureKind::Protocol,
message: "primary failure".into(),
}),
packets_sent: 0,
replies_received: 0,
duplicates: 0,
late: 0,
warning_events: 0,
cleanup_failure: Some(ManagedTargetFailure {
phase: ManagedTargetFailurePhase::Closing,
kind: ManagedTargetFailureKind::Socket,
message: "cleanup failure".into(),
}),
};
assert_eq!(
target_failure_messages(&target),
[
"irtt-rs: target edge failed (receiving protocol): primary failure",
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
]
);
}
}
+377
View File
@@ -0,0 +1,377 @@
use std::fmt::Write as _;
use irtt_stats::{Snapshot, TimeStats};
use crate::cmd::format::{
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
format_optional_ns_i128 as format_ns_i128, format_percent,
};
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
let mut out = String::new();
let packets = summary.packets;
let loss = summary.loss;
writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
)
.unwrap();
writeln!(out, " {}", "-".repeat(82)).unwrap();
write_time_row(&mut out, "RTT", &summary.rtt.primary);
if options.verbose {
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
}
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_time_row(
&mut out,
"receive delay",
&summary.one_way_delay.receive_delay,
);
write_time_row(
&mut out,
"server processing",
&summary.server_processing.processing,
);
write_time_row(&mut out, "send call", &summary.send_call);
write_time_row(&mut out, "timer error", &summary.timer_error);
writeln!(out).unwrap();
writeln!(
out,
"packets: sent={} received={} unique={} lost={} loss={}",
packets.packets_sent,
packets.packets_received,
packets.unique_replies,
loss.lost_packets,
format_percent(loss.packet_loss_percent)
)
.unwrap();
if packets.duplicates != 0 || packets.late_packets != 0 {
writeln!(
out,
"replies: duplicates={} ({}) late={} ({})",
packets.duplicates,
format_percent(loss.duplicate_percent),
packets.late_packets,
format_percent(loss.late_packets_percent)
)
.unwrap();
}
writeln!(
out,
"bytes: sent={} received={}",
packets.bytes_sent, packets.bytes_received
)
.unwrap();
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
write!(out, "server:").unwrap();
if let Some(count) = packets.server_packets_received {
write!(out, " received={count}").unwrap();
}
if let Some(window) = packets.server_received_window {
write!(out, " window={window:#x}").unwrap();
}
writeln!(out).unwrap();
}
out
}
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
if value.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.count,
format_ns_i128(value.min_ns),
format_ns_f64(value.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_i128(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
};
use irtt_stats::{StatsCollector, StatsConfig};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
#[test]
fn empty_summary_omits_optional_metric_sections() {
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
let output = format_summary(&summary);
assert!(output.contains("Metric"));
assert!(output.contains("Min"));
assert!(output.contains("Mean"));
assert!(output.contains("Median"));
assert!(output.contains("Max"));
assert!(output.contains("Stddev"));
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
assert!(!output.contains("RTT 0"));
assert!(!output.contains("server processing"));
}
#[test]
fn summary_formats_counts_and_available_metrics() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
receive_mono_ns: None,
send_wall_ns: None,
send_mono_ns: None,
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary(&collector.snapshot());
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
assert!(output.contains("bytes: sent=64 received=64"));
assert!(output.contains("RTT"));
assert!(output.contains("1.2ms"));
assert!(!output.contains("raw RTT"));
assert!(!output.contains("adjusted RTT"));
assert!(output.contains("server processing"));
assert!(output.contains("300.0µs"));
assert!(output.contains("send call"));
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
/// Returns the Median cell of the row with this label.
fn median_cell(output: &str, label: &str) -> String {
let row = output
.lines()
.find(|line| line.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
// Metric, Count, Min, Mean, Median, ...
// Counting from the right keeps multi-word labels out of the way.
row.split_whitespace()
.rev()
.nth(2)
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
.to_owned()
}
/// Collects probes whose send-call, timer-error, and server-processing
/// values each have a median distinct from their min, mean, and max, so
/// the rendered cell can only come from a real median calculation.
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
let mut collector = StatsCollector::new(config);
for (seq, (send_call_us, timer_error_us, processing_us)) in
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
.into_iter()
.enumerate()
{
let seq = seq as u32;
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
let sent_at = test_timestamp(offset);
let received_at = test_timestamp(offset + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoSent {
seq,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(send_call_us),
timer_error: Duration::from_micros(timer_error_us),
});
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: None,
effective: SignedDuration::from_nanos(1_500_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
receive_mono_ns: None,
send_wall_ns: None,
send_mono_ns: None,
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(processing_us)),
}),
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
collector.snapshot()
}
#[test]
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
// equals the row's min, mean, or max.
assert_eq!(median_cell(&output, "send call"), "20.0µs");
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
}
#[test]
fn continuous_summary_still_shows_no_medians_for_those_rows() {
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
for label in ["send call", "timer error", "server processing"] {
assert_eq!(
median_cell(&output, label),
"-",
"continuous mode retains no exact samples for {label}"
);
}
}
#[test]
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let output = format_summary_with_options(
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("raw RTT"));
assert!(output.contains("adjusted RTT"));
}
#[test]
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
median_ns: Some(-500.0),
variance_ns2: 0.0,
};
let mut out = String::new();
write_time_row(&mut out, "RTT", &stats);
assert!(out.contains("-500ns"));
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Scalar presentation vocabulary shared by the CLI frontends.
//!
//! Stream output, the run summary, and the TUI display different metrics in
//! different layouts, but they render the same *scalars*: nanosecond-scale
//! durations, percentages, and counts. This module owns those spellings so the
//! frontends cannot drift apart on units, rounding, or how an absent value is
//! rendered.
//!
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
//! deliberately not a general formatting framework: it holds exactly the
//! vocabulary more than one frontend needs.
use std::time::Duration;
use irtt_client::SignedDuration;
/// Rendering used wherever a scalar is unavailable.
pub(crate) const ABSENT: &str = "-";
/// Format a nanosecond scalar.
///
/// Unit selection and rounding are one policy for every frontend:
///
/// | magnitude | unit | precision |
/// |-----------|------|-----------|
/// | `< 1µs` | `ns` | whole |
/// | `< 1ms` | `µs` | 1 decimal |
/// | `< 1s` | `ms` | 1 decimal |
/// | `>= 1s` | `s` | 3 decimals |
///
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
/// the unit is selected from the magnitude and the sign is preserved. Values
/// that are not finite render as [`ABSENT`].
pub(crate) fn format_ns_f64(value: f64) -> String {
if !value.is_finite() {
return ABSENT.to_owned();
}
let sign = if value.is_sign_negative() { "-" } else { "" };
let magnitude = value.abs();
if magnitude < 1_000.0 {
format!("{sign}{magnitude:.0}ns")
} else if magnitude < 1_000_000.0 {
format!("{sign}{:.1}µs", magnitude / 1_000.0)
} else if magnitude < 1_000_000_000.0 {
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
} else {
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
}
}
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
pub(crate) fn format_ns_i128(value: i128) -> String {
let sign = if value < 0 { "-" } else { "" };
let magnitude = value.saturating_abs() as f64;
format!("{sign}{}", format_ns_f64(magnitude))
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(absent)
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent)
}
/// Format a duration as a nanosecond scalar.
pub(crate) fn format_duration(value: Duration) -> String {
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
}
/// Format a signed duration as a nanosecond scalar, preserving its sign.
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
format_ns_i128(value.as_nanos())
}
/// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`].
pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() {
format!("{value:.2}%")
} else {
ABSENT.to_owned()
}
}
/// Format `value` as a percentage of `total`, capped at 100%.
///
/// A zero total has no percentage to report and renders as [`ABSENT`].
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
ABSENT.to_owned()
} else {
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
}
}
/// Format a scalar count.
pub(crate) fn format_count(value: u64) -> String {
value.to_string()
}
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent)
}
fn absent() -> String {
ABSENT.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
assert_eq!(format_ns_f64(0.0), "0ns");
assert_eq!(format_ns_f64(999.0), "999ns");
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
}
#[test]
fn negative_nanosecond_scalars_keep_their_sign() {
assert_eq!(format_ns_f64(-500.0), "-500ns");
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
}
#[test]
fn exact_and_floating_nanosecond_scalars_agree() {
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
}
}
#[test]
fn microseconds_use_the_micro_sign() {
assert!(format_ns_f64(1_500.0).ends_with("µs"));
assert!(!format_ns_f64(1_500.0).contains("us"));
}
#[test]
fn durations_render_as_nanosecond_scalars() {
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
assert_eq!(
format_signed_duration(SignedDuration::from_nanos(-1_500)),
"-1.5µs"
);
}
#[test]
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
assert_eq!(format_optional_ns_i128(None), ABSENT);
assert_eq!(format_optional_ns_f64(None), ABSENT);
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
assert_eq!(format_percent(f64::NAN), ABSENT);
assert_eq!(format_percent_ratio(1, 0), ABSENT);
}
#[test]
fn percentages_and_counts_have_one_spelling() {
assert_eq!(format_percent(0.0), "0.00%");
assert_eq!(format_percent(12.345), "12.35%");
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_percent_ratio(5, 4), "100.00%");
assert_eq!(format_count(42), "42");
assert_eq!(format_optional_count(Some(0)), "0");
}
}
+10
View File
@@ -0,0 +1,10 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(any(feature = "client", feature = "tui"))]
mod format;
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "tui")]
pub mod tui;
// Additional command applets belong here behind their own feature gate.
+435
View File
@@ -0,0 +1,435 @@
use std::{net::SocketAddr, time::Duration};
use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance};
// Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)]
#[command(
name = "irtt-server",
about = "Minimal IRTT-compatible UDP server",
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
)]
pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg(
long,
required = true,
value_name = "ADDR",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
)]
pub bind: Vec<SocketAddr>,
/// HMAC key; every request must then carry a valid MAC.
#[arg(
long,
value_name = "KEY",
value_parser = parse_hmac_key,
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
)]
pub hmac: Option<String>,
/// Maximum simultaneously live sessions per listener.
#[arg(
long,
value_name = "COUNT",
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
)]
pub max_sessions: Option<usize>,
/// Maximum echo datagram size a session may negotiate, in bytes.
#[arg(
long,
value_name = "BYTES",
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
)]
pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval, and the allowance refill cadence.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
)]
pub min_interval: Option<Duration>,
/// Echo replies a session may burst before its allowance refills.
#[arg(
long,
value_name = "COUNT",
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
)]
pub burst: Option<u32>,
/// Release a session after this long without an echo request.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
)]
pub idle_timeout: Option<Duration>,
/// Maximum test duration a session may negotiate.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_positive_duration,
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
)]
pub max_duration: Option<Duration>,
/// How many timestamps this server will provide.
#[arg(
long,
value_name = "MODE",
value_enum,
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
)]
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
#[arg(
long,
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
)]
pub no_dscp: bool,
}
/// The command-line spelling of [`TimestampAllowance`].
///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
/// is a reusable crate and has no business depending on an argument parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampAllowanceArg {
/// Honor every requested timestamp placement.
Dual,
/// Provide at most one timestamp instant; both becomes midpoint.
Single,
/// Provide no timestamps.
None,
}
impl From<TimestampAllowanceArg> for TimestampAllowance {
fn from(value: TimestampAllowanceArg) -> Self {
match value {
TimestampAllowanceArg::Dual => Self::Dual,
TimestampAllowanceArg::Single => Self::Single,
TimestampAllowanceArg::None => Self::None,
}
}
}
impl ServerArgs {
/// Builds the server configuration this invocation asks for.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
let mut config = ServerConfig::default();
if let Some(key) = self.hmac.as_ref() {
config = config.with_hmac_key(key.as_bytes());
}
if let Some(value) = self.max_sessions {
config = config.with_max_sessions(value);
}
if let Some(value) = self.max_packet_length {
config = config.with_max_packet_length(value);
}
if let Some(value) = self.min_interval {
config = config.with_min_send_interval(value);
}
if let Some(value) = self.burst {
config = config.with_burst_allowance(value);
}
if let Some(value) = self.idle_timeout {
config = config.with_idle_timeout(value);
}
if let Some(value) = self.max_duration {
config = config.with_max_test_duration(value);
}
if let Some(value) = self.timestamp_allowance {
config = config.with_timestamp_allowance(value.into());
}
// A negative capability flag, so absence is the ordinary enabled state
// and the library default is what an unset flag leaves in place.
if self.no_dscp {
config = config.with_dscp_allowed(false);
}
config
}
}
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
/// enable authentication with predictable, empty key material.
fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
/// Parses a duration whose zero is meaningful server policy.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
parse_duration_units(input)
}
/// Parses a duration that must be positive, where absence means "no limit".
fn parse_positive_duration(input: &str) -> Result<Duration, String> {
let value = if input == "0" {
Duration::ZERO
} else {
parse_duration_units(input)?
};
if value.is_zero() {
return Err(
"duration must be greater than zero; omit the option for no maximum".to_owned(),
);
}
Ok(value)
}
fn parse_duration_units(input: &str) -> Result<Duration, String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() {
return Err("duration must include a value and a unit: ms, s, or m".to_owned());
}
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use super::*;
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
let mut argv = vec!["irtt-server"];
argv.extend_from_slice(args);
ServerArgs::try_parse_from(argv)
}
fn bound(args: &[&str]) -> ServerArgs {
let mut argv = vec!["--bind", "127.0.0.1:2112"];
argv.extend_from_slice(args);
parse(&argv).unwrap()
}
#[test]
fn bind_is_required_and_parses_both_families() {
assert!(
parse(&[]).is_err(),
"a server with no address serves nothing"
);
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
assert!(parse(&["--bind", "localhost:2112"]).is_err());
// Still one address per option: comma-separated syntax is not a thing
// here, and accepting it silently would be worse than rejecting it.
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
}
#[test]
fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[
"--bind",
"[::1]:2112",
"--bind",
"127.0.0.1:2112",
"--bind",
"127.0.0.1:2113",
])
.unwrap();
assert_eq!(
args.bind,
[
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
],
"listeners are served in the order they were requested"
);
}
#[test]
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
// Addresses never enter the server configuration: every listener is
// built from a clone of this one value.
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
.unwrap()
.server_config();
let several = parse(&[
"--bind",
"127.0.0.1:2112",
"--bind",
"[::1]:2112",
"--max-sessions",
"4",
])
.unwrap()
.server_config();
assert_eq!(one, several);
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
}
#[test]
fn omitted_policy_options_keep_the_library_defaults() {
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
}
#[test]
fn policy_options_map_onto_the_server_config() {
let config = bound(&[
"--hmac",
"secret",
"--max-sessions",
"512",
"--max-packet-length",
"1472",
"--min-interval",
"20ms",
"--burst",
"3",
"--idle-timeout",
"30s",
"--max-duration",
"2m",
])
.server_config();
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
assert_eq!(config.max_sessions(), 512);
assert_eq!(config.max_packet_length(), 1472);
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
assert_eq!(config.burst_allowance(), 3);
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
}
#[test]
fn the_capability_restrictions_map_onto_the_server_config() {
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
assert_eq!(
config.timestamp_allowance(),
TimestampAllowance::Single,
"a request for both timestamps is negotiated to midpoint"
);
assert!(!config.dscp_allowed());
}
#[test]
fn every_timestamp_allowance_mode_parses() {
for (argument, expected) in [
("dual", TimestampAllowance::Dual),
("single", TimestampAllowance::Single),
("none", TimestampAllowance::None),
] {
assert_eq!(
bound(&["--timestamp-allowance", argument])
.server_config()
.timestamp_allowance(),
expected,
"--timestamp-allowance {argument}"
);
}
assert!(parse(&[
"--bind",
"127.0.0.1:2112",
"--timestamp-allowance",
"midpoint"
])
.is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
}
#[test]
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
// Explicitly, and not only through the whole-config comparison above:
// these are the two values that would silently change every existing
// deployment's negotiation if the CLI restated them.
let config = bound(&["--idle-timeout", "30s"]).server_config();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
}
#[test]
fn explicit_zero_policy_values_are_preserved() {
let config = bound(&[
"--min-interval",
"0",
"--idle-timeout",
"0ms",
"--burst",
"0",
"--max-sessions",
"0",
"--max-packet-length",
"0",
])
.server_config();
assert_eq!(config.min_send_interval(), Duration::ZERO);
assert_eq!(config.idle_timeout(), Duration::ZERO);
assert_eq!(config.burst_allowance(), 0);
assert_eq!(config.max_sessions(), 0);
assert_eq!(config.max_packet_length(), 0);
}
#[test]
fn durations_require_a_supported_unit() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_whitespace_only_hmac_key_is_accepted_literally() {
let config = bound(&["--hmac", " "]).server_config();
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
}
#[test]
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod args;
pub mod run;
pub use args::ServerArgs;
pub use run::run_server;
+85
View File
@@ -0,0 +1,85 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use irtt_server::ServerSet;
use super::args::ServerArgs;
/// How often the shutdown future samples the process-wide signal flag.
///
/// The flag a signal handler sets has no async wake mechanism, so the future
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
/// after that future becomes ready, including fanning it out to the listeners.
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Runs the configured listeners until the process is asked to shut down.
///
/// The applet owns exactly one current-thread Tokio runtime and one
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
/// is an ordinary set of one and takes the same path as a set of several: one
/// construction, one shutdown future, one supervision rule. Listener
/// concurrency is async I/O concurrency and needs no worker threads.
pub fn run_server(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(serve(args, shutdown_requested))
}
async fn serve(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config();
let set = ServerSet::bind(args.bind, config).await?;
// Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started.
for addr in set.local_addrs() {
eprintln!("irtt-server: listening on {addr}");
}
set.run(shutdown_signal(shutdown_requested)).await?;
Ok(())
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
/// The ordinary invocation, and the one that keeps the single-listener case
/// on the same orchestration path as every other.
#[test]
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
#[test]
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
let args = ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:0",
"--bind",
"127.0.0.1:0",
])
.unwrap();
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
}
+287
View File
@@ -0,0 +1,287 @@
use std::time::Duration;
use clap::Parser;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection,
};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 1..,
required = true,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
)]
pub targets: Vec<TargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
/// Validate the selected targets and prepare the managed run.
///
/// The TUI always requires a target: the parser rejects an empty target
/// list, and this rejects a target set that cannot be labelled uniquely.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
},
)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct SharedOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
let mut argv = vec!["shared-only"];
argv.extend_from_slice(args);
SharedOnlyArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.prepare().unwrap().client.duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.prepare().unwrap().client.duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn multiple_positional_targets_parse() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
}
#[test]
fn repeated_labeled_targets_parse() {
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
}
#[test]
fn at_least_one_target_is_required() {
// The TUI has no target-free mode, so this is rejected at parse time
// rather than during preparation.
assert!(parse(&[]).is_err());
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
let err = args.prepare().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
assert_eq!(args.prepare().unwrap().target_count(), 2);
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
}
#[test]
fn old_target_option_is_rejected() {
assert!(parse(&["--target", "eu=host.example"]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn shared_client_options_match_tui_config_mapping() {
let shared = [
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
];
let tui_args = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
let common = parse_shared(&shared).unwrap().common;
let shared = common.to_client_config(Duration::from_secs(30));
assert_eq!(shared.duration, tui.duration);
assert_eq!(shared.interval, tui.interval);
assert_eq!(shared.length, tui.length);
assert_eq!(shared.received_stats, tui.received_stats);
assert_eq!(shared.stamp_at, tui.stamp_at);
assert_eq!(shared.clock, tui.clock);
assert_eq!(shared.dscp, tui.dscp);
assert_eq!(shared.hmac_key, tui.hmac_key);
assert_eq!(shared.server_fill, tui.server_fill);
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
}
#[test]
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
// CommonClientArgs is flattened into both the client and TUI parsers,
// so proving rejection once here covers both without duplicating it.
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--target "));
assert!(help.contains("--pacing <PACING>"));
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
+303
View File
@@ -0,0 +1,303 @@
use std::{
collections::HashSet,
io,
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{
is_shutdown_requested,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, ManagedDrainState,
},
},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_client::managed::{
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEventTryRecvError, TargetInstance,
};
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const INPUT_EVENT_WORK_BUDGET: usize = 128;
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::with_target_labels(
TuiConfig::from_args(&args, &setup),
setup.targets.iter().map(|target| target.label.clone()),
);
let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
) {
Ok(value) => value,
Err(error) => {
state.set_error(error.to_string());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(error));
}
};
let handle = owner.handle();
let mut interrupted = false;
let mut stop_requested = false;
let mut terminal_targets = HashSet::new();
let mut dropped_events = 0;
let mut subscription_closed = false;
loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if request_managed_stop_for_peer_close(
continuous,
interrupted,
handle.status().peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
let drain_state = drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
match drain_state {
ManagedDrainState::Empty => {
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
}
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
render_if_due(&mut terminal, &state, &mut next_render, false)?;
}
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let outcome = owner.join()?;
drain_final_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
state.mark_dropped_managed_events(dropped_events);
if outcome.discarded_target_outcomes != 0 {
state.set_run_error(format!(
"{} final target outcomes were discarded",
outcome.discarded_target_outcomes
));
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
state.process_target_outcome(target);
}
}
interrupted |= is_shutdown_requested(shutdown_requested);
let error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
.or_else(|| {
(!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
}),
};
if let Some(error) = error {
state.set_run_error(error.clone());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(error.into());
}
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(())
}
fn process_tui_event(
event: ManagedEvent,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
) {
match event {
ManagedEvent::Client { target, event } => state.process_target_event(&target, &event),
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
state.process_target_outcome(&outcome);
}
_ => {}
}
}
fn drain_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> ManagedDrainState {
drain_managed_events(events, dropped_events, |event| {
process_tui_event(event, state, terminal_targets);
Ok::<(), std::convert::Infallible>(())
})
.expect("processing TUI managed events is infallible")
}
fn drain_final_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) {
loop {
match events.try_recv() {
Ok(event) => process_tui_event(event, state, terminal_targets),
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
for _ in 0..INPUT_EVENT_WORK_BUDGET {
if !event::poll(Duration::ZERO)? {
break;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('c')
if key.code == KeyCode::Char('q')
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
break;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.toggle_view();
force_render = true;
}
KeyCode::Char('m') => {
state.cycle_graph_metric();
force_render = true;
}
KeyCode::Left => {
state.pan_graph_left();
force_render = true;
}
KeyCode::Right => {
state.pan_graph_right();
force_render = true;
}
KeyCode::PageUp => {
state.pan_graph_page_left();
force_render = true;
}
KeyCode::PageDown => {
state.pan_graph_page_right();
force_render = true;
}
KeyCode::Home => {
state.jump_graph_oldest();
force_render = true;
}
KeyCode::End => {
state.jump_graph_live();
force_render = true;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
state.zoom_graph_in();
force_render = true;
}
KeyCode::Char('-') => {
state.zoom_graph_out();
force_render = true;
}
KeyCode::Char('0') => {
state.reset_graph_window();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if should_render(now, *next_render, state.paused, force) {
terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL;
}
Ok(())
}
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
let render_wait = if paused {
TUI_WAIT_SLICE
} else {
next_render.saturating_duration_since(Instant::now())
};
render_wait.min(TUI_WAIT_SLICE)
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
use std::{
env,
ffi::OsString,
process::ExitCode,
sync::{atomic::AtomicBool, Arc},
};
use crate::{
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
signal::install_signal_handler,
};
/// Entry point for the `irtt-rs` multicall dispatcher binary.
///
/// Chooses an applet by subcommand or by recognized argv0 and may reference
/// every applet enabled by the active feature set.
pub fn dispatcher_main() -> ExitCode {
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
}
/// Entry point for the dedicated `irtt-client` binary.
///
/// Runs the stream client directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the client.
#[cfg(feature = "client")]
pub fn client_main() -> ExitCode {
run_with_exit_code("irtt-client", || {
run_client_applet(env::args_os().collect())
})
}
/// Entry point for the dedicated `irtt-tui` binary.
///
/// Runs the terminal UI directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the TUI.
#[cfg(feature = "tui")]
pub fn tui_main() -> ExitCode {
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
}
/// Entry point for the dedicated `irtt-server` binary.
///
/// Runs the server directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the server.
#[cfg(feature = "server")]
pub fn server_main() -> ExitCode {
run_with_exit_code("irtt-server", || {
run_server_applet(env::args_os().collect())
})
}
fn run_with_exit_code(
program: &str,
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> ExitCode {
match f() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("{program}: {err}");
ExitCode::FAILURE
}
}
}
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let (requested, argv) = match dispatch_from_argv(argv)? {
AppletDispatch::Run { applet, argv } => (applet, argv),
AppletDispatch::Help(help) => {
print!("{help}");
return Ok(());
}
};
match requested {
RequestedApplet::Client => run_client_applet(argv),
RequestedApplet::Tui => run_tui_applet(argv),
RequestedApplet::Server => run_server_applet(argv),
}
}
/// Installs the shutdown signal handler shared by every applet and hands the
/// resulting flag to `f`.
fn with_shutdown_flag(
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> {
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
f(shutdown_requested.as_ref())
}
#[cfg(feature = "client")]
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested)
})
}
#[cfg(not(feature = "client"))]
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[cfg(feature = "server")]
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::server::ServerArgs::parse_from(argv);
crate::cmd::server::run_server(args, shutdown_requested)
})
}
#[cfg(not(feature = "server"))]
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("server applet is not available; rebuild with the server feature".into())
}
#[cfg(feature = "tui")]
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
crate::cmd::tui::run_tui(args, shutdown_requested)
})
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
+8
View File
@@ -0,0 +1,8 @@
#![forbid(unsafe_code)]
pub mod applet;
pub mod cmd;
#[doc(hidden)]
pub mod entry;
pub mod shared;
pub mod signal;
+230
View File
@@ -0,0 +1,230 @@
use std::time::Duration;
use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Debug, Clone, clap::Args)]
pub struct CommonClientArgs {
/// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
/// UDP payload length.
#[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32,
/// HMAC key.
#[arg(long, value_parser = parse_hmac_key)]
pub hmac: Option<String>,
/// Clock mode to request.
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
pub clock: ClockArg,
/// Timestamp mode to request.
#[arg(
long = "tstamp",
visible_alias = "timestamps",
value_enum,
value_name = "MODE",
default_value_t = TimestampArg::Both
)]
pub tstamp: TimestampArg,
/// Received-stats mode to request.
#[arg(
long = "stats",
value_enum,
default_value_t = ReceivedStatsArg::Both,
help = "Server received-stats negotiation mode"
)]
pub stats: ReceivedStatsArg,
/// Server payload fill string to request, up to 32 bytes.
#[arg(
long = "sfill",
visible_alias = "server-fill",
value_name = "STRING",
value_parser = parse_server_fill
)]
pub server_fill: Option<String>,
/// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
pub dscp: u8,
/// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>,
/// Accept safe server restrictions during negotiation.
#[arg(long)]
pub loose: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg {
Wall,
Monotonic,
Both,
}
impl From<ClockArg> for Clock {
fn from(value: ClockArg) -> Self {
match value {
ClockArg::Wall => Self::Wall,
ClockArg::Monotonic => Self::Monotonic,
ClockArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampArg {
None,
Send,
Receive,
Both,
Midpoint,
}
impl From<TimestampArg> for StampAt {
fn from(value: TimestampArg) -> Self {
match value {
TimestampArg::None => Self::None,
TimestampArg::Send => Self::Send,
TimestampArg::Receive => Self::Receive,
TimestampArg::Both => Self::Both,
TimestampArg::Midpoint => Self::Midpoint,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReceivedStatsArg {
None,
Count,
Window,
Both,
}
impl From<ReceivedStatsArg> for ReceivedStats {
fn from(value: ReceivedStatsArg) -> Self {
match value {
ReceivedStatsArg::None => Self::None,
ReceivedStatsArg::Count => Self::Count,
ReceivedStatsArg::Window => Self::Window,
ReceivedStatsArg::Both => Self::Both,
}
}
}
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Ok(Duration::ZERO);
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
}
pub fn parse_length(input: &str) -> Result<u32, String> {
let length: u32 = input
.parse()
.map_err(|_| format!("invalid packet length {input:?}"))?;
if length > MAX_UDP_PAYLOAD_LENGTH {
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
}
Ok(length)
}
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
}
let len = input.len();
if len > MAX_SERVER_FILL_BYTES {
return Err(format!(
"server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
));
}
Ok(input.to_owned())
}
pub fn parse_dscp(input: &str) -> Result<u8, String> {
let value: u8 = input
.parse()
.map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
if value > MAX_DSCP_CODEPOINT {
return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
}
Ok(value)
}
pub fn parse_ttl(input: &str) -> Result<u32, String> {
let value: u32 = input
.parse()
.map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
if value == 0 || value > MAX_TTL {
return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
}
Ok(value)
}
@@ -0,0 +1,52 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
impl CommonClientArgs {
/// Build the shared client configuration template for a managed run.
///
/// The template carries no server address: the managed driver replaces it
/// per target from that target's configuration, so deriving one here from
/// an arbitrary target would be dead data.
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
ClientConfig {
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
server_fill: self.server_fill.clone(),
negotiation_policy: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
socket_config: SocketConfig {
ttl: self.ttl,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
+17
View File
@@ -0,0 +1,17 @@
pub mod args;
pub mod config;
pub mod prepare;
pub mod session;
pub mod targets;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
pub use session::is_shutdown_requested;
pub use targets::{
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
TargetSpec,
};
@@ -0,0 +1,251 @@
//! One preparation step from parsed arguments to a runnable managed setup.
//!
//! Both client applets accept the same target and pacing arguments and then
//! have to turn them into the same three things: a validated target set, one
//! shared [`ClientConfig`] template, and a [`ManagedPacing`]. Doing that
//! separately in each applet is how the CLI ended up deriving its client
//! configuration from an arbitrary "primary" target whose address the managed
//! driver immediately replaced per target.
use std::time::Duration;
use irtt_client::{
managed::{ManagedClientConfig, ManagedCompletionPolicy, ManagedPacing},
ClientConfig,
};
use super::{
args::CommonClientArgs,
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
PreparedTarget,
};
/// Capacity of the lossy managed presentation event channel.
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
/// Target and pacing arguments common to the client applets.
///
/// Duration is deliberately absent: the applets disagree about what a duration
/// means and what its default is, so each keeps its own.
#[derive(Debug, Clone, Copy)]
pub struct TargetSelection<'a> {
pub targets: &'a [TargetArg],
pub pacing: GroupPacingArg,
}
/// Everything a managed run needs, prepared and validated together.
#[derive(Debug, Clone)]
pub struct ManagedRunSetup {
/// Validated targets, in the order the user gave them.
pub targets: Vec<PreparedTarget>,
/// Shared client configuration template.
///
/// The managed driver supplies each target's address and authentication
/// from its [`ManagedTargetConfig`](irtt_client::managed::ManagedTargetConfig),
/// so this template carries no target of its own.
pub client: ClientConfig,
/// Send coordination across active targets.
pub pacing: ManagedPacing,
}
impl ManagedRunSetup {
/// Number of validated targets.
pub fn target_count(&self) -> usize {
self.targets.len()
}
/// Whether this run drives more than one target.
pub fn is_multi_target(&self) -> bool {
self.targets.len() > 1
}
/// Managed target configurations, in argument order.
pub fn managed_targets(&self) -> Vec<irtt_client::managed::ManagedTargetConfig> {
self.targets
.iter()
.map(|target| target.managed.clone())
.collect()
}
/// Managed driver configuration for this run.
pub fn managed_config(&self) -> ManagedClientConfig {
let target_count = self.target_count();
ManagedClientConfig {
client: self.client.clone(),
pacing: self.pacing,
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
event_capacity: MANAGED_EVENT_CAPACITY,
outcome_history_limit: target_count,
max_live_target_generations: target_count,
..ManagedClientConfig::default()
}
}
}
/// Validate the selected targets and build the shared run setup.
///
/// Targets are validated before any configuration is built, so a bad target set
/// fails before the caller can act on a half-prepared run.
pub fn prepare_managed_run(
common: &CommonClientArgs,
duration: Duration,
selection: TargetSelection<'_>,
) -> Result<ManagedRunSetup, String> {
let specs = target_specs(selection.targets)?;
let targets = prepare_managed_targets(specs)?;
Ok(ManagedRunSetup {
targets,
client: common.to_client_config(duration),
pacing: selection.pacing.into(),
})
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct CommonOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn common(args: &[&str]) -> CommonClientArgs {
let mut argv = vec!["common-only"];
argv.extend_from_slice(args);
CommonOnlyArgs::try_parse_from(argv).unwrap().common
}
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
}
fn prepare(
targets: &[TargetArg],
pacing: GroupPacingArg,
duration: Duration,
) -> Result<ManagedRunSetup, String> {
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
}
#[test]
fn a_single_target_prepares_one_managed_target_and_a_finite_duration() {
let setup = prepare(
&[unlabeled("127.0.0.1:2112")],
GroupPacingArg::Staggered,
Duration::from_secs(30),
)
.unwrap();
assert_eq!(setup.target_count(), 1);
assert!(!setup.is_multi_target());
assert_eq!(setup.targets[0].label, "127.0.0.1:2112");
assert_eq!(setup.targets[0].managed.server_addr, "127.0.0.1:2112");
assert_eq!(setup.client.duration, Some(Duration::from_secs(30)));
assert_eq!(setup.pacing, ManagedPacing::Staggered);
}
#[test]
fn a_zero_duration_prepares_a_continuous_client_config() {
let setup = prepare(
&[unlabeled("127.0.0.1:2112")],
GroupPacingArg::Staggered,
Duration::ZERO,
)
.unwrap();
assert_eq!(setup.client.duration, None);
}
#[test]
fn multiple_targets_keep_argument_order_and_share_one_client_config() {
let setup = prepare(
&[
unlabeled("host-a:2112"),
unlabeled("host-b:2112"),
labeled("sg", "sg.example.test:2112"),
],
GroupPacingArg::Burst,
Duration::from_secs(10),
)
.unwrap();
assert!(setup.is_multi_target());
let labels: Vec<_> = setup
.targets
.iter()
.map(|target| target.label.as_str())
.collect();
assert_eq!(labels, ["host-a:2112", "host-b:2112", "sg"]);
assert_eq!(setup.targets[2].managed.server_addr, "sg.example.test:2112");
assert_eq!(setup.pacing, ManagedPacing::Burst);
let config = setup.managed_config();
assert_eq!(config.outcome_history_limit, 3);
assert_eq!(config.max_live_target_generations, 3);
assert_eq!(config.event_capacity, MANAGED_EVENT_CAPACITY);
assert_eq!(config.pacing, ManagedPacing::Burst);
assert_eq!(config.client, setup.client);
assert_eq!(setup.managed_targets().len(), 3);
}
#[test]
fn the_shared_client_config_carries_no_target_of_its_own() {
// The managed driver replaces the address, and the CLI's key, per
// target, so the template must not pin either to one target.
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")];
let setup = prepare_managed_run(
&common(&["--hmac", "secret"]),
Duration::from_secs(10),
TargetSelection {
targets: &targets,
pacing: GroupPacingArg::Staggered,
},
)
.unwrap();
assert_eq!(
setup.client.server_addr,
ClientConfig::default().server_addr
);
assert_eq!(setup.client.hmac_key, Some(b"secret".to_vec()));
// Per-target authentication is not exposed on the command line, so no
// target carries an override of the shared key.
assert!(setup
.targets
.iter()
.all(|target| target.managed.auth.is_none()));
}
#[test]
fn an_empty_target_set_is_rejected_with_the_list_columns_hint() {
let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
assert!(err.contains("at least one target is required"));
assert!(err.contains("--list-columns"));
}
#[test]
fn a_duplicate_label_is_rejected_before_any_configuration_is_built() {
let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err =
prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
assert!(err.contains("duplicate target label"));
}
}
@@ -0,0 +1,141 @@
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(any(feature = "client", feature = "tui"))]
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
#[cfg(any(feature = "client", feature = "tui"))]
pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128;
#[cfg(any(feature = "client", feature = "tui"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ManagedDrainState {
Empty,
Closed,
BudgetExhausted,
}
/// Drain a bounded amount of lossy presentation work before returning to the
/// frontend control loop.
#[cfg(any(feature = "client", feature = "tui"))]
pub(crate) fn drain_managed_events<E>(
events: &mut ManagedEventSubscription,
dropped_events: &mut u64,
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
) -> Result<ManagedDrainState, E> {
for _ in 0..MANAGED_EVENT_WORK_BUDGET {
match events.try_recv() {
Ok(event) => process(event)?,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
Err(ManagedEventTryRecvError::Empty) => return Ok(ManagedDrainState::Empty),
Err(ManagedEventTryRecvError::Closed) => return Ok(ManagedDrainState::Closed),
}
}
Ok(ManagedDrainState::BudgetExhausted)
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn peer_close_run_error(
continuous: bool,
interrupted: bool,
peer_closed_target_outcomes: u64,
) -> Option<String> {
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
return None;
}
let sessions = if peer_closed_target_outcomes == 1 {
"target session"
} else {
"target sessions"
};
Some(format!(
"continuous run ended because of peer closure ({peer_closed_target_outcomes} {sessions})"
))
}
pub fn request_managed_stop_for_peer_close(
continuous: bool,
interrupted: bool,
peer_closed_target_outcomes: u64,
stop_requested: &mut bool,
) -> bool {
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
return false;
}
request_managed_stop_once(stop_requested)
}
pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
if *stop_requested {
return false;
}
*stop_requested = true;
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
assert_eq!(peer_close_run_error(false, false, 1), None);
assert_eq!(peer_close_run_error(true, false, 0), None);
assert_eq!(peer_close_run_error(true, true, 1), None);
assert_eq!(
peer_close_run_error(true, false, 1).as_deref(),
Some("continuous run ended because of peer closure (1 target session)")
);
assert_eq!(
peer_close_run_error(true, false, 3).as_deref(),
Some("continuous run ended because of peer closure (3 target sessions)")
);
}
#[test]
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
let mut stop_requested = false;
assert!(!request_managed_stop_for_peer_close(
false,
false,
1,
&mut stop_requested,
));
assert!(!request_managed_stop_for_peer_close(
true,
true,
1,
&mut stop_requested,
));
assert!(!request_managed_stop_for_peer_close(
true,
false,
0,
&mut stop_requested,
));
assert!(!stop_requested);
assert!(request_managed_stop_for_peer_close(
true,
false,
1,
&mut stop_requested,
));
assert!(stop_requested);
assert!(!request_managed_stop_for_peer_close(
true,
false,
1,
&mut stop_requested,
));
}
}
@@ -0,0 +1,226 @@
use std::collections::HashSet;
use clap::ValueEnum;
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetArg {
pub label: Option<String>,
pub addr: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub addr: String,
}
#[derive(Debug, Clone)]
pub struct PreparedTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
Burst,
}
impl From<GroupPacingArg> for ManagedPacing {
fn from(value: GroupPacingArg) -> Self {
match value {
GroupPacingArg::Staggered => Self::Staggered,
GroupPacingArg::Burst => Self::Burst,
}
}
}
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
match input.split_once('=') {
None => Ok(TargetArg {
label: None,
addr: input.to_owned(),
}),
Some((label, addr)) => {
if label.is_empty() {
return Err("target label must not be empty".to_owned());
}
if addr.is_empty() {
return Err("target address must not be empty".to_owned());
}
Ok(TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
})
}
}
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
for target in targets {
let label = match &target.label {
Some(label) => label.clone(),
None => {
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
*count += 1;
if *count == 1 {
target.addr.clone()
} else {
format!("{}#{}", target.addr, *count)
}
}
};
specs.push(TargetSpec {
label,
addr: target.addr.clone(),
});
}
if specs.is_empty() {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label));
}
}
Ok(specs)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
targets.push(PreparedTarget {
label: spec.label.clone(),
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
});
}
Ok(targets)
}
#[cfg(test)]
mod tests {
use super::*;
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
}
#[test]
fn parse_target_without_equals_is_unlabeled() {
let target = parse_target("host.example").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example");
let target = parse_target("host.example:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example:2112");
let target = parse_target("[::1]:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "[::1]:2112");
}
#[test]
fn parse_target_with_equals_splits_on_first() {
let target = parse_target("eu=host.example").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example");
let target = parse_target("eu=host.example:2112").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example:2112");
}
#[test]
fn parse_target_rejects_empty_label_or_address() {
assert!(parse_target("=host.example").is_err());
assert!(parse_target("eu=").is_err());
}
#[test]
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
let specs = target_specs(&targets).unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn target_specs_preserve_argument_order_with_mixed_labels() {
let targets = vec![
unlabeled("local"),
labeled("eu", "eu.example"),
unlabeled("backup"),
labeled("us", "us.example"),
];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["local", "eu", "backup", "us"]);
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
}
#[test]
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["foo", "eu", "foo#2"]);
}
#[test]
fn target_specs_reject_generated_suffix_collisions() {
let targets = vec![
unlabeled("host.example"),
unlabeled("host.example"),
labeled("host.example#2", "other.example"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
let specs = vec![
TargetSpec {
label: "a".to_owned(),
addr: "example.test".to_owned(),
},
TargetSpec {
label: "b".to_owned(),
addr: "example.test".to_owned(),
},
];
let targets = prepare_managed_targets(specs).unwrap();
assert_eq!(targets[0].managed.server_addr, "example.test");
assert_eq!(targets[1].managed.server_addr, "example.test");
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(any(feature = "client", feature = "tui"))]
pub mod client;
+10
View File
@@ -0,0 +1,10 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
+300
View File
@@ -0,0 +1,300 @@
use std::process::{Command, Output};
fn irtt_rs() -> Command {
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
}
#[cfg(unix)]
fn irtt_rs_with_arg0(arg0: &str) -> Command {
use std::os::unix::process::CommandExt;
let mut command = irtt_rs();
command.arg0(arg0);
command
}
/// Copies a dedicated binary to `name` in a temp dir and returns a `Command`
/// invoking it under that name, proving dedicated binaries decide their role
/// from the entry point they were built with, not from argv0.
#[cfg(all(unix, any(feature = "client", feature = "server")))]
fn copied_binary(cargo_bin_exe: &str, name: &str) -> Command {
let dir = std::env::temp_dir().join(format!(
"irtt-copied-binary-test-{}-{}",
std::process::id(),
name
));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join(name);
std::fs::copy(cargo_bin_exe, &dest).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&dest).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dest, perms).unwrap();
}
Command::new(dest)
}
fn output_text(output: &Output) -> String {
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&output.stdout));
text.push_str(&String::from_utf8_lossy(&output.stderr));
text
}
#[test]
fn canonical_help_shows_dispatcher_help() {
let output = irtt_rs().arg("--help").output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[test]
fn canonical_without_applet_errors() {
let output = irtt_rs().output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("choose an applet"), "{text}");
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[cfg(feature = "client")]
#[test]
fn canonical_client_subcommand_dispatches_to_client() {
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(not(feature = "client"))]
#[test]
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(feature = "server")]
#[test]
fn canonical_server_subcommand_dispatches_to_server() {
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
assert!(text.contains("--bind <ADDR>"));
assert!(text.contains("--max-sessions <COUNT>"));
assert!(text.contains("--idle-timeout <DURATION>"));
assert!(text.contains("--timestamp-allowance <MODE>"));
assert!(text.contains("--no-dscp"));
}
#[cfg(not(feature = "server"))]
#[test]
fn canonical_server_subcommand_reports_unavailable_when_disabled() {
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("server applet is not available; rebuild with the server feature"),
"{text}"
);
}
#[cfg(feature = "client")]
#[test]
fn dedicated_client_binary_always_enters_the_client_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
}
#[cfg(feature = "tui")]
#[test]
fn dedicated_tui_binary_always_enters_the_tui_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-tui"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible TUI client"));
assert!(text.contains("--duration <DURATION>"));
}
#[cfg(feature = "server")]
#[test]
fn dedicated_server_binary_always_enters_the_server_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
assert!(text.contains("--bind <ADDR>"));
}
#[cfg(all(unix, feature = "client"))]
#[test]
fn renaming_the_dedicated_client_binary_does_not_change_its_role() {
let output = copied_binary(env!("CARGO_BIN_EXE_irtt-client"), "whatever")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
}
#[cfg(all(unix, feature = "server"))]
#[test]
fn renaming_the_dedicated_server_binary_does_not_change_its_role() {
let output = copied_binary(env!("CARGO_BIN_EXE_irtt-server"), "irtt-cli")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
}
#[cfg(unix)]
#[cfg(feature = "client")]
#[test]
fn client_applet_name_dispatches_to_client() {
let output = irtt_rs_with_arg0("irtt-client")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(all(unix, not(feature = "client")))]
#[test]
fn client_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-client")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(all(unix, feature = "tui"))]
#[test]
fn tui_applet_name_dispatches_to_tui_when_enabled() {
let output = irtt_rs_with_arg0("irtt-tui")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible TUI client"));
assert!(text.contains("--duration <DURATION>"));
}
#[cfg(all(unix, not(feature = "tui")))]
#[test]
fn tui_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-tui")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("TUI applet is not available"), "{text}");
}
#[cfg(all(unix, feature = "server"))]
#[test]
fn server_applet_name_dispatches_to_server_when_enabled() {
let output = irtt_rs_with_arg0("irtt-server")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
assert!(text.contains("--bind <ADDR>"));
}
#[cfg(all(unix, not(feature = "server")))]
#[test]
fn server_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-server")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("server applet is not available"), "{text}");
}
#[cfg(unix)]
#[test]
fn unknown_irtt_applet_name_errors() {
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
assert!(text.contains("irtt-client"));
assert!(text.contains("irtt-tui"));
assert!(text.contains("irtt-server"));
}
#[cfg(unix)]
#[test]
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
let output = irtt_rs_with_arg0("irtt-cli").output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-cli'"), "{text}");
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::mpsc,
thread::{self, JoinHandle},
time::Duration,
};
use irtt_server::{Server, ServerConfig};
use tokio::sync::oneshot;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
pub struct InTreeServer {
pub addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
impl InTreeServer {
pub fn start() -> Self {
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let done = thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build in-tree test server runtime");
runtime.block_on(async move {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let mut server = Server::bind(bind_addr, ServerConfig::default()).await?;
startup_tx
.send(server.local_addr()?)
.expect("test stopped waiting for in-tree server startup");
server
.run(async {
let _ = shutdown_rx.await;
})
.await
})
});
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(addr) => addr,
Err(error) => {
let _ = shutdown_tx.send(());
let result = done.join().expect("in-tree test server thread panicked");
panic!("in-tree test server failed before startup ({error}): {result:?}");
}
};
Self {
addr,
shutdown: Some(shutdown_tx),
done: Some(done),
}
}
fn stop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(done) = self.done.take() {
done.join()
.expect("in-tree test server thread panicked")
.expect("in-tree test server stopped with an error");
}
}
}
impl Drop for InTreeServer {
fn drop(&mut self) {
self.stop();
}
}