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

+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"));
}
}