diff --git a/README.md b/README.md index 72388a7..85a631b 100644 --- a/README.md +++ b/README.md @@ -84,12 +84,15 @@ cargo install irtt-rs --locked This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Each applet can also be built in isolation with `--no-default-features` plus the applet's own feature, for -example a lightweight client-only install: +example a lightweight client-only install without statistics: ```sh cargo install irtt-rs --locked --no-default-features --features client ``` +Add `stats` (`--features client,stats`) to retain statistics in a client-only +build. Statistics are enabled by default; the TUI always enables them. + For development, or to install directly from a checkout: ```sh @@ -165,6 +168,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly. | --- | --- | | `--no-default-features` | `irtt-rs` | | `--no-default-features --features client` | `irtt-rs`, `irtt-client` | +| `--no-default-features --features client,stats` | `irtt-rs`, `irtt-client` (with statistics) | | `--no-default-features --features server` | `irtt-rs`, `irtt-server` | | `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` | | Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` | diff --git a/crates/irtt-app/Cargo.toml b/crates/irtt-app/Cargo.toml index 57bc9a7..b3c32ee 100644 --- a/crates/irtt-app/Cargo.toml +++ b/crates/irtt-app/Cargo.toml @@ -41,10 +41,11 @@ required-features = ["server"] doc = false [features] -default = ["client", "server", "tui"] -client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] +default = ["client", "server", "tui", "stats"] +client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto"] +stats = ["dep:irtt-stats"] server = ["dep:irtt-server", "dep:irtt-proto", "dep:tokio"] -tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] +tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] [dependencies] chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } diff --git a/crates/irtt-app/README.md b/crates/irtt-app/README.md index 2e2afda..cc8363f 100644 --- a/crates/irtt-app/README.md +++ b/crates/irtt-app/README.md @@ -11,8 +11,13 @@ Installing this crate builds: - `irtt-tui` — the interactive terminal UI (feature `tui`). - `irtt-server` — the dedicated server binary (feature `server`). -All three features are enabled by default, so a plain `cargo install irtt-rs` -installs everything. +The `client`, `server`, `tui`, and `stats` features are enabled by default, so a +plain `cargo install irtt-rs` installs everything. The TUI requires statistics +and enables `stats` automatically. + +For a client without statistics, use +`cargo install irtt-rs --locked --no-default-features --features client --bin irtt-client`. +Use `--features client,stats` for a client-only build with statistics. ## This is an application, not a library diff --git a/crates/irtt-app/src/cmd/client/args.rs b/crates/irtt-app/src/cmd/client/args.rs index dd9ac7b..40690ee 100644 --- a/crates/irtt-app/src/cmd/client/args.rs +++ b/crates/irtt-app/src/cmd/client/args.rs @@ -55,7 +55,7 @@ pub struct ClientArgs { default_value = "10s", value_parser = parse_test_duration, help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)", - long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted).\n\nUnless --no-stats is selected, finite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF." + long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted).\n\nWhen statistics support is compiled in and --no-stats is not selected, finite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF." )] pub duration: Duration, @@ -64,6 +64,7 @@ pub struct ClientArgs { pub continuous: bool, /// Disable statistics collection and final summaries; retain measurement event output. + /// Accepted even when statistics support is not compiled in. #[arg(long)] pub no_stats: bool, diff --git a/crates/irtt-app/src/cmd/client/mod.rs b/crates/irtt-app/src/cmd/client/mod.rs index 44bbb87..3cf31f5 100644 --- a/crates/irtt-app/src/cmd/client/mod.rs +++ b/crates/irtt-app/src/cmd/client/mod.rs @@ -1,6 +1,7 @@ pub mod args; pub mod output; pub mod run; +#[cfg(feature = "stats")] pub mod summary; pub use args::{ClientArgs, HeaderMode, OutputFormat}; diff --git a/crates/irtt-app/src/cmd/client/output.rs b/crates/irtt-app/src/cmd/client/output.rs index a89746c..a6af2a2 100644 --- a/crates/irtt-app/src/cmd/client/output.rs +++ b/crates/irtt-app/src/cmd/client/output.rs @@ -9,6 +9,7 @@ use irtt_client::{ ServerTiming, SignedDuration, WarningKind, }; +#[cfg(feature = "stats")] use irtt_stats::{EventStatsUpdate, IpdvPairUpdate}; use super::args::{HeaderMode, OutputFormat}; @@ -48,10 +49,12 @@ impl OutputConfig { }) } + #[cfg(feature = "stats")] pub(super) fn prints_summary(&self) -> bool { self.format.prints_summary() } + #[cfg(feature = "stats")] pub(super) fn summary_verbose(&self) -> bool { self.verbose } @@ -96,15 +99,22 @@ impl OutputConfig { &self, event: &ClientEvent, target: Option<&str>, - stats: Option<&EventStatsUpdate>, + #[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>, ) -> Option { let row = OutputRow::from_event(event); if self.default_table_rows && row.is_default_table_hidden() { return None; } + #[cfg(feature = "stats")] + let ipdv_ns = row + .seq() + .and_then(|seq| ipdv_pair(stats, seq)) + .map(|pair| pair.rtt_ipdv_ns); + #[cfg(not(feature = "stats"))] + let ipdv_ns = None; let context = RenderContext { - stats, + ipdv_ns, target, verbose: self.verbose, }; @@ -155,7 +165,7 @@ impl OutputConfig { #[derive(Debug, Clone, Copy)] struct RenderContext<'a> { - stats: Option<&'a EventStatsUpdate>, + ipdv_ns: Option, target: Option<&'a str>, verbose: bool, } @@ -817,18 +827,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti .reply() .and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server)) .map(|value| CellValue::Integer(signed_duration_us(value))), - Column::Ipdv => row - .seq() - .and_then(|seq| ipdv_pair(context.stats, seq)) - .map(|pair| { - CellValue::text(format_signed_duration(SignedDuration::from_nanos( - pair.rtt_ipdv_ns, - ))) - }), - Column::IpdvUs => row - .seq() - .and_then(|seq| ipdv_pair(context.stats, seq)) - .map(|pair| CellValue::Integer(pair.rtt_ipdv_ns / 1_000)), + Column::Ipdv => context.ipdv_ns.map(|value| { + CellValue::text(format_signed_duration(SignedDuration::from_nanos(value))) + }), + Column::IpdvUs => context + .ipdv_ns + .map(|value| CellValue::Integer(value / 1_000)), Column::ServerProcessing => row .reply() .and_then(|reply| reply.server_timing.and_then(|timing| timing.processing)) @@ -935,6 +939,7 @@ fn server_timing_i64( .map(|value| CellValue::Integer(i128::from(value))) } +#[cfg(feature = "stats")] fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> { let stats = stats?; stats diff --git a/crates/irtt-app/src/cmd/client/run.rs b/crates/irtt-app/src/cmd/client/run.rs index ed1006e..9dcb760 100644 --- a/crates/irtt-app/src/cmd/client/run.rs +++ b/crates/irtt-app/src/cmd/client/run.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, + collections::{HashSet, VecDeque}, io::{self, BufRead, Write}, sync::Arc, thread, @@ -7,7 +7,7 @@ use std::{ use irtt_client::{ managed::{ - ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus, + ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance, }, ClientEvent, @@ -16,26 +16,25 @@ use irtt_client::{ use super::{args::ClientArgs, output::OutputConfig}; use crate::shared::client::{ - expected_probe_count, parse_stdin_target_set, + parse_stdin_target_set, session::{ drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close, - request_managed_stop_once, should_print_final_summary, + request_managed_stop_once, }, worker::ManagedWorker, ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT, }; +#[cfg(feature = "stats")] use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig}; -const MIB: u64 = 1024 * 1024; +#[cfg(feature = "stats")] +use crate::shared::client::{expected_probe_count, session::should_print_final_summary}; +#[cfg(feature = "stats")] +use irtt_client::managed::ManagedStatus; +#[cfg(feature = "stats")] +use std::collections::{BTreeMap, BTreeSet}; -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 MAX_STDIN_RECORD_BYTES: usize = 64 * 1024; pub async fn run_stream( @@ -50,6 +49,7 @@ pub async fn run_stream( result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?, _ = shutdown.wait_for(|requested| *requested) => return Ok(()), }; + #[cfg(feature = "stats")] let multi_target = setup.is_multi_target(); let output_config = OutputConfig::new( args.format, @@ -58,6 +58,7 @@ pub async fn run_stream( args.verbose, ) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + #[cfg(feature = "stats")] if let Some(warning) = reconnect_stats_warning(&args) { eprintln!("{warning}"); } @@ -65,9 +66,9 @@ pub async fn run_stream( return run_stdin_stream(setup, output_config, shutdown, &args).await; } let continuous = args.is_continuous(); - let target_count = setup.target_count(); - if let Some(warning) = finite_stats_memory_warning(&args, target_count) { + #[cfg(feature = "stats")] + if let Some(warning) = finite_stats_memory_warning(&args, setup.target_count()) { eprintln!("{warning}"); } if *shutdown.borrow() { @@ -86,15 +87,20 @@ pub async fn run_stream( let owner = ManagedWorker::start(task, handle.clone())?; let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stream_output = StreamOutput { + #[cfg(feature = "stats")] continuous: args.is_continuous(), + #[cfg(feature = "stats")] collect_stats: !args.no_stats, config: output_config, header_printed: false, + #[cfg(feature = "stats")] print_final_summary: false, + #[cfg(feature = "stats")] show_running_only_summary_note: false, out: &mut stdout, }; + #[cfg(feature = "stats")] let mut stats = setup .targets .iter() @@ -133,7 +139,7 @@ pub async fn run_stream( _ = shutdown.changed(), if !interrupted => {} changed = status.changed() => { if changed.is_err() { break; } } event = events.recv() => match event { - Ok(event) => process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?, + Ok(event) => process_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?, Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { dropped_events = dropped_events.saturating_add(count); } @@ -146,7 +152,13 @@ pub async fn run_stream( } let outcome = owner.join().await?; drain_final_events(&mut events, &mut dropped_events, |event| { - process_event(event, &mut stream_output, &mut stats, &mut terminal_targets) + process_event( + event, + &mut stream_output, + #[cfg(feature = "stats")] + &mut stats, + &mut terminal_targets, + ) })?; if let Some(warning) = dropped_event_warning(dropped_events) { eprintln!("{warning}"); @@ -180,22 +192,25 @@ pub async fn run_stream( }) }), }; - stream_output.print_final_summary = - !args.no_stats && !args.no_test && 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 Some(target_stats) = stats.get(&target.label) else { - continue; - }; - if multi_target - && stream_output.print_final_summary - && stream_output.config.prints_summary() - { - writeln!(stream_output.out)?; - writeln!(stream_output.out, "target: {}", target.label)?; + #[cfg(feature = "stats")] + { + stream_output.print_final_summary = + !args.no_stats && !args.no_test && 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 Some(target_stats) = stats.get(&target.label) else { + continue; + }; + 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.print_summary(target_stats)?; } stream_output.out.flush()?; if let Some(error) = terminal_error { @@ -370,16 +385,18 @@ impl BoundedTargetSet { fn process_stdin_event( event: ManagedEvent, stream_output: &mut StreamOutput<'_, W>, - stats: &mut BTreeMap, + #[cfg(feature = "stats")] stats: &mut BTreeMap, terminal_targets: &mut BoundedTargetSet, ) -> io::Result<()> { match event { + #[cfg(feature = "stats")] ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => { stats .entry(target) .or_insert_with(|| StatsCollector::new(stats_config(true))); } ManagedEvent::Client { target, event } => { + #[cfg(feature = "stats")] let update = stream_output.collect_stats.then(|| { let collector = stats .entry(target.clone()) @@ -389,7 +406,12 @@ fn process_stdin_event( } collector.process(&event) }); - stream_output.print_event(&event, Some(target.id.as_str()), update.as_ref())?; + stream_output.print_event( + &event, + Some(target.id.as_str()), + #[cfg(feature = "stats")] + update.as_ref(), + )?; } ManagedEvent::TargetFinished { outcome } if terminal_targets.insert(outcome.target.clone()) => @@ -401,6 +423,7 @@ fn process_stdin_event( Ok(()) } +#[cfg(feature = "stats")] fn reconcile_stdin_stats( stats: &mut BTreeMap, status: &ManagedStatus, @@ -417,6 +440,7 @@ fn reconcile_stdin_stats( }); } +#[cfg(feature = "stats")] fn snapshot_stdin_summary_targets( stats: &mut BTreeMap, status: &ManagedStatus, @@ -470,14 +494,19 @@ async fn run_stdin_stream( let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stream_output = StreamOutput { + #[cfg(feature = "stats")] continuous: args.is_continuous(), + #[cfg(feature = "stats")] collect_stats: !args.no_stats, config: output_config, header_printed: false, + #[cfg(feature = "stats")] print_final_summary: false, + #[cfg(feature = "stats")] show_running_only_summary_note: false, out: &mut stdout, }; + #[cfg(feature = "stats")] let mut stats = BTreeMap::new(); let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT); let mut stdin_changed = false; @@ -491,6 +520,7 @@ async fn run_stdin_stream( let mut dropped_events = 0_u64; let mut stop_requested = false; let mut stop = None; + #[cfg(feature = "stats")] let mut summary_targets = None; loop { let snapshot = status.borrow_and_update().clone(); @@ -542,16 +572,20 @@ async fn run_stdin_stream( } if stop.is_some() { preparing = None; - summary_targets = Some(snapshot_stdin_summary_targets( - &mut stats, - &snapshot, - !args.no_stats, - )); + #[cfg(feature = "stats")] + { + summary_targets = Some(snapshot_stdin_summary_targets( + &mut stats, + &snapshot, + !args.no_stats, + )); + } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); } } } + #[cfg(feature = "stats")] reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref()); if snapshot.final_outcome.is_some() { break; @@ -573,7 +607,10 @@ async fn run_stdin_stream( } Err(error) => { stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}"))); - summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats)); + #[cfg(feature = "stats")] + { + summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats)); + } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); } } } @@ -594,14 +631,18 @@ async fn run_stdin_stream( } Err(_) => { stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned())); - summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats)); + #[cfg(feature = "stats")] + { + summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats)); + } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); } } } } event = events.recv() => match event { Ok(event) => { - process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?; + process_stdin_event(event, &mut stream_output, #[cfg(feature = "stats")] &mut stats, &mut terminal_targets)?; + #[cfg(feature = "stats")] reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref()); } Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { @@ -614,7 +655,13 @@ async fn run_stdin_stream( let outcome = owner.join().await?; drain_final_events(&mut events, &mut dropped_events, |event| { - process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets) + process_stdin_event( + event, + &mut stream_output, + #[cfg(feature = "stats")] + &mut stats, + &mut terminal_targets, + ) })?; if let Some(warning) = dropped_event_warning(dropped_events) { eprintln!("{warning}"); @@ -631,25 +678,28 @@ async fn run_stdin_stream( ); } - let print_summary = - !args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof)); - stream_output.print_final_summary = print_summary; - stream_output.show_running_only_summary_note = print_summary; - if let Some(summary_targets) = summary_targets { - let multi_target = summary_targets.len() > 1; - for target in summary_targets { - let stats = stats - .get(&target) - .expect("summary snapshot retains every selected target collector"); - if multi_target && stream_output.config.prints_summary() { - writeln!(stream_output.out)?; - writeln!( - stream_output.out, - "target: {} (generation {})", - target.id, target.generation - )?; + #[cfg(feature = "stats")] + { + let print_summary = + !args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof)); + stream_output.print_final_summary = print_summary; + stream_output.show_running_only_summary_note = print_summary; + if let Some(summary_targets) = summary_targets { + let multi_target = summary_targets.len() > 1; + for target in summary_targets { + let stats = stats + .get(&target) + .expect("summary snapshot retains every selected target collector"); + if multi_target && stream_output.config.prints_summary() { + writeln!(stream_output.out)?; + writeln!( + stream_output.out, + "target: {} (generation {})", + target.id, target.generation + )?; + } + stream_output.print_summary(stats)?; } - stream_output.print_summary(stats)?; } } stream_output.out.flush()?; @@ -668,11 +718,12 @@ async fn run_stdin_stream( fn process_event( event: ManagedEvent, stream_output: &mut StreamOutput<'_, W>, - stats: &mut BTreeMap, + #[cfg(feature = "stats")] stats: &mut BTreeMap, terminal_targets: &mut HashSet, ) -> io::Result<()> { match event { ManagedEvent::Client { target, event } => { + #[cfg(feature = "stats")] let update = stream_output.collect_stats.then(|| { let collector = stats .get_mut(target.id.as_str()) @@ -683,7 +734,12 @@ fn process_event( } collector.process(&event) }); - stream_output.print_event(&event, Some(target.id.as_str()), update.as_ref())?; + stream_output.print_event( + &event, + Some(target.id.as_str()), + #[cfg(feature = "stats")] + update.as_ref(), + )?; } ManagedEvent::TargetFinished { outcome } => { terminal_targets.insert(outcome.target.clone()); @@ -717,15 +773,24 @@ fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec { messages } fn dropped_event_warning(dropped_events: u64) -> Option { - (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" })) + let affected = if cfg!(feature = "stats") { + "output and statistics" + } else { + "output" + }; + (dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; {affected} may be incomplete", if dropped_events == 1 { "" } else { "s" })) } struct StreamOutput<'a, W: Write> { + #[cfg(feature = "stats")] continuous: bool, + #[cfg(feature = "stats")] collect_stats: bool, config: OutputConfig, header_printed: bool, + #[cfg(feature = "stats")] print_final_summary: bool, + #[cfg(feature = "stats")] show_running_only_summary_note: bool, out: &'a mut W, } @@ -734,10 +799,15 @@ impl StreamOutput<'_, W> { &mut self, event: &ClientEvent, target: Option<&str>, - update: Option<&EventStatsUpdate>, + #[cfg(feature = "stats")] update: Option<&EventStatsUpdate>, ) -> io::Result<()> { self.print_header()?; - if let Some(line) = self.config.render_event(event, target, update) { + if let Some(line) = self.config.render_event( + event, + target, + #[cfg(feature = "stats")] + update, + ) { writeln!(self.out, "{line}")?; } Ok(()) @@ -753,6 +823,7 @@ impl StreamOutput<'_, W> { Ok(()) } + #[cfg(feature = "stats")] fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> { if self.print_final_summary && self.config.prints_summary() { write!( @@ -771,6 +842,7 @@ impl StreamOutput<'_, W> { } } +#[cfg(feature = "stats")] fn stats_config(continuous: bool) -> StatsConfig { if continuous { StatsConfig::continuous() @@ -778,7 +850,17 @@ fn stats_config(continuous: bool) -> StatsConfig { StatsConfig::finite() } } +#[cfg(feature = "stats")] fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option { + 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; if args.no_stats || args.no_test || args.is_continuous() || args.duration.is_zero() { return None; } @@ -807,7 +889,7 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}")) } +#[cfg(feature = "stats")] fn reconnect_stats_warning(args: &ClientArgs) -> Option<&'static str> { (args.reconnect && !args.no_stats).then_some("irtt-rs: warning: statistics spanning multiple sessions may not accurately represent one uninterrupted measurement run; use --no-stats for measurement events only. Session statistics reset on reconnect.") } - diff --git a/crates/irtt-app/src/cmd/format.rs b/crates/irtt-app/src/cmd/format.rs index ceeb5b2..cc60a13 100644 --- a/crates/irtt-app/src/cmd/format.rs +++ b/crates/irtt-app/src/cmd/format.rs @@ -57,12 +57,13 @@ pub(crate) fn format_ns_i128(value: i128) -> String { } /// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. +#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))] pub(crate) fn format_optional_ns_i128(value: Option) -> String { value.map(format_ns_i128).unwrap_or_else(absent) } /// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. -#[cfg(feature = "client")] +#[cfg(all(feature = "client", feature = "stats"))] pub(crate) fn format_optional_ns_f64(value: Option) -> String { value.map(format_ns_f64).unwrap_or_else(absent) } @@ -80,6 +81,7 @@ pub(crate) fn format_signed_duration(value: SignedDuration) -> String { /// Format a percentage with two decimals; non-finite values render as /// [`ABSENT`]. +#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))] pub(crate) fn format_percent(value: f64) -> String { if value.is_finite() { format!("{value:.2}%") @@ -112,6 +114,7 @@ pub(crate) fn format_optional_count(value: Option) -> String { value.map(format_count).unwrap_or_else(absent) } +#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))] fn absent() -> String { ABSENT.to_owned() } diff --git a/crates/irtt-app/src/version.rs b/crates/irtt-app/src/version.rs index ae2063d..42c3916 100644 --- a/crates/irtt-app/src/version.rs +++ b/crates/irtt-app/src/version.rs @@ -22,10 +22,14 @@ pub(crate) fn parse(argv: Vec, report: fn() -> String #[cfg(any(feature = "client", feature = "tui"))] pub(crate) fn client() -> String { + #[cfg(feature = "stats")] + let stats = format!(" stats: {}\n", irtt_stats::VERSION); + #[cfg(not(feature = "stats"))] + let stats = " stats: disabled\n"; format!( - " client: {}\n stats: {}\n proto: {}\n", + " client: {}\n{} proto: {}\n", irtt_client::VERSION, - irtt_stats::VERSION, + stats, irtt_proto::VERSION, ) } diff --git a/docs/irtt-client.md b/docs/irtt-client.md index 5cfccfe..d968c81 100644 --- a/docs/irtt-client.md +++ b/docs/irtt-client.md @@ -222,7 +222,20 @@ memory a long run retains. ### Optional recovery and event-only runs -`--no-stats` disables statistics collection and final summaries while preserving individual measurement output. Statistics-dependent columns (such as IPDV) are unavailable. This does not remove the `irtt-stats` dependency. +`--no-stats` disables statistics collection and final summaries while preserving individual measurement output. Statistics-dependent columns (such as IPDV) are unavailable. This runtime option does not remove the `irtt-stats` dependency. + +To omit statistics at compile time, build with: + +```sh +cargo build --release -p irtt-rs --bin irtt-client --no-default-features --features client +``` + +Use `--features client,stats` to include statistics in a client-only build. +Default builds include statistics, and `tui` always enables them. Without +compiled statistics, `--no-stats` remains accepted, summaries and statistics +warnings are disabled, and IPDV columns retain their names with absent values +(`-` in tables, empty CSV/TSV fields, and JSON `null`). Measurement events and +protocol-level received statistics remain available. `--reconnect` enables automatic recovery for individual targets in continuous mode (`--continuous` or `--duration 0`). Failed connections, closed sessions, and unresponsive servers are retried without interrupting other targets. Retries use exponential backoff from 250 ms up to 10 seconds. diff --git a/docs/irtt-rs.md b/docs/irtt-rs.md index 8052dfc..4b66bef 100644 --- a/docs/irtt-rs.md +++ b/docs/irtt-rs.md @@ -49,9 +49,12 @@ reports which feature to rebuild with, and `irtt-rs --help` lists which applets the current build actually contains. ```sh -cargo install irtt-rs --locked --no-default-features --features client +cargo install irtt-rs --locked --no-default-features --features client,stats ``` +Use `--features client` to omit statistics. The default build includes the +`stats` feature, and `tui` always enables it. + ## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server `irtt-rs` is packaging and dispatch only. All probing, display, and server