feat(app): make statistics collection optional at compile time
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 29s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 6s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m6s
Netem Interop / netem interop (push) Failing after 17s
Supply chain / cargo-deny advisories (push) Failing after 3s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Supply chain / cargo-deny bans licenses sources (push) Failing after 4s
Supply chain / actionlint (push) Successful in 4s
Build binaries / Build (arm-musleabi) (push) Successful in 4m31s
Build binaries / Build (aarch64-musl) (push) Successful in 4m41s
Build binaries / Build (arm-musleabihf) (push) Successful in 4m7s
Build binaries / Build (armv7-musleabi) (push) Successful in 4m2s
Build binaries / Build (armv7-musleabihf) (push) Successful in 4m26s
Build binaries / Build (i686-musl) (push) Successful in 4m20s
Build binaries / Build (mips-musl) (push) Successful in 4m18s
Build binaries / Build (mips64-muslabi64) (push) Successful in 4m19s
Build binaries / Build (mips64el-muslabi64) (push) Successful in 4m8s
Build binaries / Build (mipsel-musl) (push) Successful in 4m7s
Build binaries / Build (riscv64gc-musl) (push) Successful in 4m11s
Build binaries / Build (x86_64-musl) (push) Successful in 4m17s

Decouple statistics collection into a dedicated feature to enable lighter client-only builds.
This commit is contained in:
Nils Andreas Svee committed 2026-10-26 15:35:13 +07:00
1 parent ea8734a7aa
commit 9bb3fd63d8
11 files changed
+215 -93

No files matched your search

+5 -1
View File
@@ -84,12 +84,15 @@ cargo install irtt-rs --locked
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`, This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in `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 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 ```sh
cargo install irtt-rs --locked --no-default-features --features client 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: For development, or to install directly from a checkout:
```sh ```sh
@@ -165,6 +168,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
| --- | --- | | --- | --- |
| `--no-default-features` | `irtt-rs` | | `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` | | `--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 server` | `irtt-rs`, `irtt-server` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` | | `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` | | Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
+4 -3
View File
@@ -41,10 +41,11 @@ required-features = ["server"]
doc = false doc = false
[features] [features]
default = ["client", "server", "tui"] default = ["client", "server", "tui", "stats"]
client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-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"] 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] [dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
+7 -2
View File
@@ -11,8 +11,13 @@ Installing this crate builds:
- `irtt-tui` — the interactive terminal UI (feature `tui`). - `irtt-tui` — the interactive terminal UI (feature `tui`).
- `irtt-server` — the dedicated server binary (feature `server`). - `irtt-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs` The `client`, `server`, `tui`, and `stats` features are enabled by default, so a
installs everything. 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 ## This is an application, not a library
+2 -1
View File
@@ -55,7 +55,7 @@ pub struct ClientArgs {
default_value = "10s", default_value = "10s",
value_parser = parse_test_duration, value_parser = parse_test_duration,
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)", 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, pub duration: Duration,
@@ -64,6 +64,7 @@ pub struct ClientArgs {
pub continuous: bool, pub continuous: bool,
/// Disable statistics collection and final summaries; retain measurement event output. /// Disable statistics collection and final summaries; retain measurement event output.
/// Accepted even when statistics support is not compiled in.
#[arg(long)] #[arg(long)]
pub no_stats: bool, pub no_stats: bool,
+1
View File
@@ -1,6 +1,7 @@
pub mod args; pub mod args;
pub mod output; pub mod output;
pub mod run; pub mod run;
#[cfg(feature = "stats")]
pub mod summary; pub mod summary;
pub use args::{ClientArgs, HeaderMode, OutputFormat}; pub use args::{ClientArgs, HeaderMode, OutputFormat};
+20 -15
View File
@@ -9,6 +9,7 @@ use irtt_client::{
ServerTiming, SignedDuration, WarningKind, ServerTiming, SignedDuration, WarningKind,
}; };
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate}; use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
use super::args::{HeaderMode, OutputFormat}; use super::args::{HeaderMode, OutputFormat};
@@ -48,10 +49,12 @@ impl OutputConfig {
}) })
} }
#[cfg(feature = "stats")]
pub(super) fn prints_summary(&self) -> bool { pub(super) fn prints_summary(&self) -> bool {
self.format.prints_summary() self.format.prints_summary()
} }
#[cfg(feature = "stats")]
pub(super) fn summary_verbose(&self) -> bool { pub(super) fn summary_verbose(&self) -> bool {
self.verbose self.verbose
} }
@@ -96,15 +99,22 @@ impl OutputConfig {
&self, &self,
event: &ClientEvent, event: &ClientEvent,
target: Option<&str>, target: Option<&str>,
stats: Option<&EventStatsUpdate>, #[cfg(feature = "stats")] stats: Option<&EventStatsUpdate>,
) -> Option<String> { ) -> Option<String> {
let row = OutputRow::from_event(event); let row = OutputRow::from_event(event);
if self.default_table_rows && row.is_default_table_hidden() { if self.default_table_rows && row.is_default_table_hidden() {
return None; 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 { let context = RenderContext {
stats, ipdv_ns,
target, target,
verbose: self.verbose, verbose: self.verbose,
}; };
@@ -155,7 +165,7 @@ impl OutputConfig {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
struct RenderContext<'a> { struct RenderContext<'a> {
stats: Option<&'a EventStatsUpdate>, ipdv_ns: Option<i128>,
target: Option<&'a str>, target: Option<&'a str>,
verbose: bool, verbose: bool,
} }
@@ -817,18 +827,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
.reply() .reply()
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server)) .and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
.map(|value| CellValue::Integer(signed_duration_us(value))), .map(|value| CellValue::Integer(signed_duration_us(value))),
Column::Ipdv => row Column::Ipdv => context.ipdv_ns.map(|value| {
.seq() CellValue::text(format_signed_duration(SignedDuration::from_nanos(value)))
.and_then(|seq| ipdv_pair(context.stats, seq)) }),
.map(|pair| { Column::IpdvUs => context
CellValue::text(format_signed_duration(SignedDuration::from_nanos( .ipdv_ns
pair.rtt_ipdv_ns, .map(|value| CellValue::Integer(value / 1_000)),
)))
}),
Column::IpdvUs => row
.seq()
.and_then(|seq| ipdv_pair(context.stats, seq))
.map(|pair| CellValue::Integer(pair.rtt_ipdv_ns / 1_000)),
Column::ServerProcessing => row Column::ServerProcessing => row
.reply() .reply()
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing)) .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))) .map(|value| CellValue::Integer(i128::from(value)))
} }
#[cfg(feature = "stats")]
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> { fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
let stats = stats?; let stats = stats?;
stats stats
+148 -66
View File
@@ -1,5 +1,5 @@
use std::{ use std::{
collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, collections::{HashSet, VecDeque},
io::{self, BufRead, Write}, io::{self, BufRead, Write},
sync::Arc, sync::Arc,
thread, thread,
@@ -7,7 +7,7 @@ use std::{
use irtt_client::{ use irtt_client::{
managed::{ managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus, ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent,
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
}, },
ClientEvent, ClientEvent,
@@ -16,26 +16,25 @@ use irtt_client::{
use super::{args::ClientArgs, output::OutputConfig}; use super::{args::ClientArgs, output::OutputConfig};
use crate::shared::client::{ use crate::shared::client::{
expected_probe_count, parse_stdin_target_set, parse_stdin_target_set,
session::{ session::{
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close, 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, worker::ManagedWorker,
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT, ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
}; };
#[cfg(feature = "stats")]
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig}; 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; const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub async fn run_stream( 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))?, result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
_ = shutdown.wait_for(|requested| *requested) => return Ok(()), _ = shutdown.wait_for(|requested| *requested) => return Ok(()),
}; };
#[cfg(feature = "stats")]
let multi_target = setup.is_multi_target(); let multi_target = setup.is_multi_target();
let output_config = OutputConfig::new( let output_config = OutputConfig::new(
args.format, args.format,
@@ -58,6 +58,7 @@ pub async fn run_stream(
args.verbose, args.verbose,
) )
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
#[cfg(feature = "stats")]
if let Some(warning) = reconnect_stats_warning(&args) { if let Some(warning) = reconnect_stats_warning(&args) {
eprintln!("{warning}"); eprintln!("{warning}");
} }
@@ -65,9 +66,9 @@ pub async fn run_stream(
return run_stdin_stream(setup, output_config, shutdown, &args).await; return run_stdin_stream(setup, output_config, shutdown, &args).await;
} }
let continuous = args.is_continuous(); 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}"); eprintln!("{warning}");
} }
if *shutdown.borrow() { if *shutdown.borrow() {
@@ -86,15 +87,20 @@ pub async fn run_stream(
let owner = ManagedWorker::start(task, handle.clone())?; let owner = ManagedWorker::start(task, handle.clone())?;
let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(), continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats, collect_stats: !args.no_stats,
config: output_config, config: output_config,
header_printed: false, header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false, print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
}; };
#[cfg(feature = "stats")]
let mut stats = setup let mut stats = setup
.targets .targets
.iter() .iter()
@@ -133,7 +139,7 @@ pub async fn run_stream(
_ = shutdown.changed(), if !interrupted => {} _ = shutdown.changed(), if !interrupted => {}
changed = status.changed() => { if changed.is_err() { break; } } changed = status.changed() => { if changed.is_err() { break; } }
event = events.recv() => match event { 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)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
dropped_events = dropped_events.saturating_add(count); dropped_events = dropped_events.saturating_add(count);
} }
@@ -146,7 +152,13 @@ pub async fn run_stream(
} }
let outcome = owner.join().await?; let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| { 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) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
@@ -180,22 +192,25 @@ pub async fn run_stream(
}) })
}), }),
}; };
stream_output.print_final_summary = #[cfg(feature = "stats")]
!args.no_stats && !args.no_test && should_print_final_summary(continuous, interrupted); {
stream_output.show_running_only_summary_note = stream_output.print_final_summary =
continuous && interrupted && stream_output.print_final_summary; !args.no_stats && !args.no_test && should_print_final_summary(continuous, interrupted);
for target in &setup.targets { stream_output.show_running_only_summary_note =
let Some(target_stats) = stats.get(&target.label) else { continuous && interrupted && stream_output.print_final_summary;
continue; for target in &setup.targets {
}; let Some(target_stats) = stats.get(&target.label) else {
if multi_target continue;
&& stream_output.print_final_summary };
&& stream_output.config.prints_summary() if multi_target
{ && stream_output.print_final_summary
writeln!(stream_output.out)?; && stream_output.config.prints_summary()
writeln!(stream_output.out, "target: {}", target.label)?; {
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()?; stream_output.out.flush()?;
if let Some(error) = terminal_error { if let Some(error) = terminal_error {
@@ -370,16 +385,18 @@ impl BoundedTargetSet {
fn process_stdin_event<W: Write>( fn process_stdin_event<W: Write>(
event: ManagedEvent, event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>, stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>, #[cfg(feature = "stats")] stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet, terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> { ) -> io::Result<()> {
match event { match event {
#[cfg(feature = "stats")]
ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => { ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => {
stats stats
.entry(target) .entry(target)
.or_insert_with(|| StatsCollector::new(stats_config(true))); .or_insert_with(|| StatsCollector::new(stats_config(true)));
} }
ManagedEvent::Client { target, event } => { ManagedEvent::Client { target, event } => {
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| { let update = stream_output.collect_stats.then(|| {
let collector = stats let collector = stats
.entry(target.clone()) .entry(target.clone())
@@ -389,7 +406,12 @@ fn process_stdin_event<W: Write>(
} }
collector.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 } ManagedEvent::TargetFinished { outcome }
if terminal_targets.insert(outcome.target.clone()) => if terminal_targets.insert(outcome.target.clone()) =>
@@ -401,6 +423,7 @@ fn process_stdin_event<W: Write>(
Ok(()) Ok(())
} }
#[cfg(feature = "stats")]
fn reconcile_stdin_stats( fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>, stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus, status: &ManagedStatus,
@@ -417,6 +440,7 @@ fn reconcile_stdin_stats(
}); });
} }
#[cfg(feature = "stats")]
fn snapshot_stdin_summary_targets( fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>, stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus, status: &ManagedStatus,
@@ -470,14 +494,19 @@ async fn run_stdin_stream(
let mut stdout = io::LineWriter::new(io::stdout().lock()); let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput { let mut stream_output = StreamOutput {
#[cfg(feature = "stats")]
continuous: args.is_continuous(), continuous: args.is_continuous(),
#[cfg(feature = "stats")]
collect_stats: !args.no_stats, collect_stats: !args.no_stats,
config: output_config, config: output_config,
header_printed: false, header_printed: false,
#[cfg(feature = "stats")]
print_final_summary: false, print_final_summary: false,
#[cfg(feature = "stats")]
show_running_only_summary_note: false, show_running_only_summary_note: false,
out: &mut stdout, out: &mut stdout,
}; };
#[cfg(feature = "stats")]
let mut stats = BTreeMap::new(); let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT); let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut stdin_changed = false; let mut stdin_changed = false;
@@ -491,6 +520,7 @@ async fn run_stdin_stream(
let mut dropped_events = 0_u64; let mut dropped_events = 0_u64;
let mut stop_requested = false; let mut stop_requested = false;
let mut stop = None; let mut stop = None;
#[cfg(feature = "stats")]
let mut summary_targets = None; let mut summary_targets = None;
loop { loop {
let snapshot = status.borrow_and_update().clone(); let snapshot = status.borrow_and_update().clone();
@@ -542,16 +572,20 @@ async fn run_stdin_stream(
} }
if stop.is_some() { if stop.is_some() {
preparing = None; preparing = None;
summary_targets = Some(snapshot_stdin_summary_targets( #[cfg(feature = "stats")]
&mut stats, {
&snapshot, summary_targets = Some(snapshot_stdin_summary_targets(
!args.no_stats, &mut stats,
)); &snapshot,
!args.no_stats,
));
}
if request_managed_stop_once(&mut stop_requested) { if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop()); drop(handle.stop());
} }
} }
} }
#[cfg(feature = "stats")]
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref()); reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
if snapshot.final_outcome.is_some() { if snapshot.final_outcome.is_some() {
break; break;
@@ -573,7 +607,10 @@ async fn run_stdin_stream(
} }
Err(error) => { Err(error) => {
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {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()); } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
} }
} }
@@ -594,14 +631,18 @@ async fn run_stdin_stream(
} }
Err(_) => { Err(_) => {
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned())); 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()); } if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
} }
} }
} }
event = events.recv() => match event { event = events.recv() => match event {
Ok(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()); reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
@@ -614,7 +655,13 @@ async fn run_stdin_stream(
let outcome = owner.join().await?; let outcome = owner.join().await?;
drain_final_events(&mut events, &mut dropped_events, |event| { 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) { if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}"); eprintln!("{warning}");
@@ -631,25 +678,28 @@ async fn run_stdin_stream(
); );
} }
let print_summary = #[cfg(feature = "stats")]
!args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof)); {
stream_output.print_final_summary = print_summary; let print_summary =
stream_output.show_running_only_summary_note = print_summary; !args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
if let Some(summary_targets) = summary_targets { stream_output.print_final_summary = print_summary;
let multi_target = summary_targets.len() > 1; stream_output.show_running_only_summary_note = print_summary;
for target in summary_targets { if let Some(summary_targets) = summary_targets {
let stats = stats let multi_target = summary_targets.len() > 1;
.get(&target) for target in summary_targets {
.expect("summary snapshot retains every selected target collector"); let stats = stats
if multi_target && stream_output.config.prints_summary() { .get(&target)
writeln!(stream_output.out)?; .expect("summary snapshot retains every selected target collector");
writeln!( if multi_target && stream_output.config.prints_summary() {
stream_output.out, writeln!(stream_output.out)?;
"target: {} (generation {})", writeln!(
target.id, target.generation stream_output.out,
)?; "target: {} (generation {})",
target.id, target.generation
)?;
}
stream_output.print_summary(stats)?;
} }
stream_output.print_summary(stats)?;
} }
} }
stream_output.out.flush()?; stream_output.out.flush()?;
@@ -668,11 +718,12 @@ async fn run_stdin_stream(
fn process_event<W: Write>( fn process_event<W: Write>(
event: ManagedEvent, event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>, stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<String, StatsCollector>, #[cfg(feature = "stats")] stats: &mut BTreeMap<String, StatsCollector>,
terminal_targets: &mut HashSet<TargetInstance>, terminal_targets: &mut HashSet<TargetInstance>,
) -> io::Result<()> { ) -> io::Result<()> {
match event { match event {
ManagedEvent::Client { target, event } => { ManagedEvent::Client { target, event } => {
#[cfg(feature = "stats")]
let update = stream_output.collect_stats.then(|| { let update = stream_output.collect_stats.then(|| {
let collector = stats let collector = stats
.get_mut(target.id.as_str()) .get_mut(target.id.as_str())
@@ -683,7 +734,12 @@ fn process_event<W: Write>(
} }
collector.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 } => { ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone()); terminal_targets.insert(outcome.target.clone());
@@ -717,15 +773,24 @@ fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
messages messages
} }
fn dropped_event_warning(dropped_events: u64) -> Option<String> { 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" })) 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> { struct StreamOutput<'a, W: Write> {
#[cfg(feature = "stats")]
continuous: bool, continuous: bool,
#[cfg(feature = "stats")]
collect_stats: bool, collect_stats: bool,
config: OutputConfig, config: OutputConfig,
header_printed: bool, header_printed: bool,
#[cfg(feature = "stats")]
print_final_summary: bool, print_final_summary: bool,
#[cfg(feature = "stats")]
show_running_only_summary_note: bool, show_running_only_summary_note: bool,
out: &'a mut W, out: &'a mut W,
} }
@@ -734,10 +799,15 @@ impl<W: Write> StreamOutput<'_, W> {
&mut self, &mut self,
event: &ClientEvent, event: &ClientEvent,
target: Option<&str>, target: Option<&str>,
update: Option<&EventStatsUpdate>, #[cfg(feature = "stats")] update: Option<&EventStatsUpdate>,
) -> io::Result<()> { ) -> io::Result<()> {
self.print_header()?; 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}")?; writeln!(self.out, "{line}")?;
} }
Ok(()) Ok(())
@@ -753,6 +823,7 @@ impl<W: Write> StreamOutput<'_, W> {
Ok(()) Ok(())
} }
#[cfg(feature = "stats")]
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> { fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
if self.print_final_summary && self.config.prints_summary() { if self.print_final_summary && self.config.prints_summary() {
write!( write!(
@@ -771,6 +842,7 @@ impl<W: Write> StreamOutput<'_, W> {
} }
} }
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig { fn stats_config(continuous: bool) -> StatsConfig {
if continuous { if continuous {
StatsConfig::continuous() StatsConfig::continuous()
@@ -778,7 +850,17 @@ fn stats_config(continuous: bool) -> StatsConfig {
StatsConfig::finite() StatsConfig::finite()
} }
} }
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> { fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
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() { if args.no_stats || args.no_test || args.is_continuous() || args.duration.is_zero() {
return None; 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}")) 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> { 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.") (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.")
} }
+4 -1
View File
@@ -57,12 +57,13 @@ pub(crate) fn format_ns_i128(value: i128) -> String {
} }
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. /// 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<i128>) -> String { pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(absent) value.map(format_ns_i128).unwrap_or_else(absent)
} }
/// Format an optional nanosecond scalar, rendering `None` as [`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<f64>) -> String { pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent) 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 /// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`]. /// [`ABSENT`].
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
pub(crate) fn format_percent(value: f64) -> String { pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() { if value.is_finite() {
format!("{value:.2}%") format!("{value:.2}%")
@@ -112,6 +114,7 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent) value.map(format_count).unwrap_or_else(absent)
} }
#[cfg(any(all(feature = "client", feature = "stats"), feature = "tui"))]
fn absent() -> String { fn absent() -> String {
ABSENT.to_owned() ABSENT.to_owned()
} }
+6 -2
View File
@@ -22,10 +22,14 @@ pub(crate) fn parse<T: clap::Parser>(argv: Vec<OsString>, report: fn() -> String
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(any(feature = "client", feature = "tui"))]
pub(crate) fn client() -> String { 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!( format!(
" client: {}\n stats: {}\n proto: {}\n", " client: {}\n{} proto: {}\n",
irtt_client::VERSION, irtt_client::VERSION,
irtt_stats::VERSION, stats,
irtt_proto::VERSION, irtt_proto::VERSION,
) )
} }
+14 -1
View File
@@ -222,7 +222,20 @@ memory a long run retains.
### Optional recovery and event-only runs ### 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. `--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.
+4 -1
View File
@@ -49,9 +49,12 @@ reports which feature to rebuild with, and `irtt-rs --help` lists which
applets the current build actually contains. applets the current build actually contains.
```sh ```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 ## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
`irtt-rs` is packaging and dispatch only. All probing, display, and server `irtt-rs` is packaging and dispatch only. All probing, display, and server