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.
378 lines
13 KiB
Rust
378 lines
13 KiB
Rust
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"));
|
|
}
|
|
}
|