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"));
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Scalar presentation vocabulary shared by the CLI frontends.
//!
//! Stream output, the run summary, and the TUI display different metrics in
//! different layouts, but they render the same *scalars*: nanosecond-scale
//! durations, percentages, and counts. This module owns those spellings so the
//! frontends cannot drift apart on units, rounding, or how an absent value is
//! rendered.
//!
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
//! deliberately not a general formatting framework: it holds exactly the
//! vocabulary more than one frontend needs.
use std::time::Duration;
use irtt_client::SignedDuration;
/// Rendering used wherever a scalar is unavailable.
pub(crate) const ABSENT: &str = "-";
/// Format a nanosecond scalar.
///
/// Unit selection and rounding are one policy for every frontend:
///
/// | magnitude | unit | precision |
/// |-----------|------|-----------|
/// | `< 1µs` | `ns` | whole |
/// | `< 1ms` | `µs` | 1 decimal |
/// | `< 1s` | `ms` | 1 decimal |
/// | `>= 1s` | `s` | 3 decimals |
///
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
/// the unit is selected from the magnitude and the sign is preserved. Values
/// that are not finite render as [`ABSENT`].
pub(crate) fn format_ns_f64(value: f64) -> String {
if !value.is_finite() {
return ABSENT.to_owned();
}
let sign = if value.is_sign_negative() { "-" } else { "" };
let magnitude = value.abs();
if magnitude < 1_000.0 {
format!("{sign}{magnitude:.0}ns")
} else if magnitude < 1_000_000.0 {
format!("{sign}{:.1}µs", magnitude / 1_000.0)
} else if magnitude < 1_000_000_000.0 {
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
} else {
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
}
}
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
pub(crate) fn format_ns_i128(value: i128) -> String {
let sign = if value < 0 { "-" } else { "" };
let magnitude = value.saturating_abs() as f64;
format!("{sign}{}", format_ns_f64(magnitude))
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
value.map(format_ns_i128).unwrap_or_else(absent)
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent)
}
/// Format a duration as a nanosecond scalar.
pub(crate) fn format_duration(value: Duration) -> String {
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
}
/// Format a signed duration as a nanosecond scalar, preserving its sign.
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
format_ns_i128(value.as_nanos())
}
/// Format a percentage with two decimals; non-finite values render as
/// [`ABSENT`].
pub(crate) fn format_percent(value: f64) -> String {
if value.is_finite() {
format!("{value:.2}%")
} else {
ABSENT.to_owned()
}
}
/// Format `value` as a percentage of `total`, capped at 100%.
///
/// A zero total has no percentage to report and renders as [`ABSENT`].
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
ABSENT.to_owned()
} else {
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
}
}
/// Format a scalar count.
pub(crate) fn format_count(value: u64) -> String {
value.to_string()
}
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent)
}
fn absent() -> String {
ABSENT.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
assert_eq!(format_ns_f64(0.0), "0ns");
assert_eq!(format_ns_f64(999.0), "999ns");
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
}
#[test]
fn negative_nanosecond_scalars_keep_their_sign() {
assert_eq!(format_ns_f64(-500.0), "-500ns");
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
}
#[test]
fn exact_and_floating_nanosecond_scalars_agree() {
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
}
}
#[test]
fn microseconds_use_the_micro_sign() {
assert!(format_ns_f64(1_500.0).ends_with("µs"));
assert!(!format_ns_f64(1_500.0).contains("us"));
}
#[test]
fn durations_render_as_nanosecond_scalars() {
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
assert_eq!(
format_signed_duration(SignedDuration::from_nanos(-1_500)),
"-1.5µs"
);
}
#[test]
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
assert_eq!(format_optional_ns_i128(None), ABSENT);
assert_eq!(format_optional_ns_f64(None), ABSENT);
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
assert_eq!(format_percent(f64::NAN), ABSENT);
assert_eq!(format_percent_ratio(1, 0), ABSENT);
}
#[test]
fn percentages_and_counts_have_one_spelling() {
assert_eq!(format_percent(0.0), "0.00%");
assert_eq!(format_percent(12.345), "12.35%");
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_percent_ratio(5, 4), "100.00%");
assert_eq!(format_count(42), "42");
assert_eq!(format_optional_count(Some(0)), "0");
}
}
+10
View File
@@ -0,0 +1,10 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(any(feature = "client", feature = "tui"))]
mod format;
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "tui")]
pub mod tui;
// Additional command applets belong here behind their own feature gate.
+435
View File
@@ -0,0 +1,435 @@
use std::{net::SocketAddr, time::Duration};
use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance};
// Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)]
#[command(
name = "irtt-server",
about = "Minimal IRTT-compatible UDP server",
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
)]
pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg(
long,
required = true,
value_name = "ADDR",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
)]
pub bind: Vec<SocketAddr>,
/// HMAC key; every request must then carry a valid MAC.
#[arg(
long,
value_name = "KEY",
value_parser = parse_hmac_key,
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
)]
pub hmac: Option<String>,
/// Maximum simultaneously live sessions per listener.
#[arg(
long,
value_name = "COUNT",
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
)]
pub max_sessions: Option<usize>,
/// Maximum echo datagram size a session may negotiate, in bytes.
#[arg(
long,
value_name = "BYTES",
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
)]
pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval, and the allowance refill cadence.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
)]
pub min_interval: Option<Duration>,
/// Echo replies a session may burst before its allowance refills.
#[arg(
long,
value_name = "COUNT",
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
)]
pub burst: Option<u32>,
/// Release a session after this long without an echo request.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
)]
pub idle_timeout: Option<Duration>,
/// Maximum test duration a session may negotiate.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_positive_duration,
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
)]
pub max_duration: Option<Duration>,
/// How many timestamps this server will provide.
#[arg(
long,
value_name = "MODE",
value_enum,
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
)]
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
#[arg(
long,
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
)]
pub no_dscp: bool,
}
/// The command-line spelling of [`TimestampAllowance`].
///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
/// is a reusable crate and has no business depending on an argument parser.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampAllowanceArg {
/// Honor every requested timestamp placement.
Dual,
/// Provide at most one timestamp instant; both becomes midpoint.
Single,
/// Provide no timestamps.
None,
}
impl From<TimestampAllowanceArg> for TimestampAllowance {
fn from(value: TimestampAllowanceArg) -> Self {
match value {
TimestampAllowanceArg::Dual => Self::Dual,
TimestampAllowanceArg::Single => Self::Single,
TimestampAllowanceArg::None => Self::None,
}
}
}
impl ServerArgs {
/// Builds the server configuration this invocation asks for.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
let mut config = ServerConfig::default();
if let Some(key) = self.hmac.as_ref() {
config = config.with_hmac_key(key.as_bytes());
}
if let Some(value) = self.max_sessions {
config = config.with_max_sessions(value);
}
if let Some(value) = self.max_packet_length {
config = config.with_max_packet_length(value);
}
if let Some(value) = self.min_interval {
config = config.with_min_send_interval(value);
}
if let Some(value) = self.burst {
config = config.with_burst_allowance(value);
}
if let Some(value) = self.idle_timeout {
config = config.with_idle_timeout(value);
}
if let Some(value) = self.max_duration {
config = config.with_max_test_duration(value);
}
if let Some(value) = self.timestamp_allowance {
config = config.with_timestamp_allowance(value.into());
}
// A negative capability flag, so absence is the ordinary enabled state
// and the library default is what an unset flag leaves in place.
if self.no_dscp {
config = config.with_dscp_allowed(false);
}
config
}
}
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
/// enable authentication with predictable, empty key material.
fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
/// Parses a duration whose zero is meaningful server policy.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
parse_duration_units(input)
}
/// Parses a duration that must be positive, where absence means "no limit".
fn parse_positive_duration(input: &str) -> Result<Duration, String> {
let value = if input == "0" {
Duration::ZERO
} else {
parse_duration_units(input)?
};
if value.is_zero() {
return Err(
"duration must be greater than zero; omit the option for no maximum".to_owned(),
);
}
Ok(value)
}
fn parse_duration_units(input: &str) -> Result<Duration, String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() {
return Err("duration must include a value and a unit: ms, s, or m".to_owned());
}
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use super::*;
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
let mut argv = vec!["irtt-server"];
argv.extend_from_slice(args);
ServerArgs::try_parse_from(argv)
}
fn bound(args: &[&str]) -> ServerArgs {
let mut argv = vec!["--bind", "127.0.0.1:2112"];
argv.extend_from_slice(args);
parse(&argv).unwrap()
}
#[test]
fn bind_is_required_and_parses_both_families() {
assert!(
parse(&[]).is_err(),
"a server with no address serves nothing"
);
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
assert!(parse(&["--bind", "localhost:2112"]).is_err());
// Still one address per option: comma-separated syntax is not a thing
// here, and accepting it silently would be worse than rejecting it.
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
}
#[test]
fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[
"--bind",
"[::1]:2112",
"--bind",
"127.0.0.1:2112",
"--bind",
"127.0.0.1:2113",
])
.unwrap();
assert_eq!(
args.bind,
[
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
],
"listeners are served in the order they were requested"
);
}
#[test]
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
// Addresses never enter the server configuration: every listener is
// built from a clone of this one value.
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
.unwrap()
.server_config();
let several = parse(&[
"--bind",
"127.0.0.1:2112",
"--bind",
"[::1]:2112",
"--max-sessions",
"4",
])
.unwrap()
.server_config();
assert_eq!(one, several);
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
}
#[test]
fn omitted_policy_options_keep_the_library_defaults() {
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
}
#[test]
fn policy_options_map_onto_the_server_config() {
let config = bound(&[
"--hmac",
"secret",
"--max-sessions",
"512",
"--max-packet-length",
"1472",
"--min-interval",
"20ms",
"--burst",
"3",
"--idle-timeout",
"30s",
"--max-duration",
"2m",
])
.server_config();
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
assert_eq!(config.max_sessions(), 512);
assert_eq!(config.max_packet_length(), 1472);
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
assert_eq!(config.burst_allowance(), 3);
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
}
#[test]
fn the_capability_restrictions_map_onto_the_server_config() {
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
assert_eq!(
config.timestamp_allowance(),
TimestampAllowance::Single,
"a request for both timestamps is negotiated to midpoint"
);
assert!(!config.dscp_allowed());
}
#[test]
fn every_timestamp_allowance_mode_parses() {
for (argument, expected) in [
("dual", TimestampAllowance::Dual),
("single", TimestampAllowance::Single),
("none", TimestampAllowance::None),
] {
assert_eq!(
bound(&["--timestamp-allowance", argument])
.server_config()
.timestamp_allowance(),
expected,
"--timestamp-allowance {argument}"
);
}
assert!(parse(&[
"--bind",
"127.0.0.1:2112",
"--timestamp-allowance",
"midpoint"
])
.is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
}
#[test]
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
// Explicitly, and not only through the whole-config comparison above:
// these are the two values that would silently change every existing
// deployment's negotiation if the CLI restated them.
let config = bound(&["--idle-timeout", "30s"]).server_config();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
}
#[test]
fn explicit_zero_policy_values_are_preserved() {
let config = bound(&[
"--min-interval",
"0",
"--idle-timeout",
"0ms",
"--burst",
"0",
"--max-sessions",
"0",
"--max-packet-length",
"0",
])
.server_config();
assert_eq!(config.min_send_interval(), Duration::ZERO);
assert_eq!(config.idle_timeout(), Duration::ZERO);
assert_eq!(config.burst_allowance(), 0);
assert_eq!(config.max_sessions(), 0);
assert_eq!(config.max_packet_length(), 0);
}
#[test]
fn durations_require_a_supported_unit() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_whitespace_only_hmac_key_is_accepted_literally() {
let config = bound(&["--hmac", " "]).server_config();
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
}
#[test]
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod args;
pub mod run;
pub use args::ServerArgs;
pub use run::run_server;
+85
View File
@@ -0,0 +1,85 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use irtt_server::ServerSet;
use super::args::ServerArgs;
/// How often the shutdown future samples the process-wide signal flag.
///
/// The flag a signal handler sets has no async wake mechanism, so the future
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
/// after that future becomes ready, including fanning it out to the listeners.
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
/// Runs the configured listeners until the process is asked to shut down.
///
/// The applet owns exactly one current-thread Tokio runtime and one
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
/// is an ordinary set of one and takes the same path as a set of several: one
/// construction, one shutdown future, one supervision rule. Listener
/// concurrency is async I/O concurrency and needs no worker threads.
pub fn run_server(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(serve(args, shutdown_requested))
}
async fn serve(
args: ServerArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config();
let set = ServerSet::bind(args.bind, config).await?;
// Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started.
for addr in set.local_addrs() {
eprintln!("irtt-server: listening on {addr}");
}
set.run(shutdown_signal(shutdown_requested)).await?;
Ok(())
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
/// The ordinary invocation, and the one that keeps the single-listener case
/// on the same orchestration path as every other.
#[test]
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
#[test]
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
let args = ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:0",
"--bind",
"127.0.0.1:0",
])
.unwrap();
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
}
+287
View File
@@ -0,0 +1,287 @@
use std::time::Duration;
use clap::Parser;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
ManagedRunSetup, TargetArg, TargetSelection,
};
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 1..,
required = true,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
)]
pub targets: Vec<TargetArg>,
/// Managed group pacing for multi-target mode.
#[arg(
long,
value_enum,
default_value_t = GroupPacingArg::Staggered,
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
)]
pub pacing: GroupPacingArg,
#[arg(
long,
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
/// Validate the selected targets and prepare the managed run.
///
/// The TUI always requires a target: the parser rejects an empty target
/// list, and this rejects a target set that cannot be labelled uniquely.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
},
)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct SharedOnlyArgs {
#[command(flatten)]
common: CommonClientArgs,
}
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
let mut argv = vec!["shared-only"];
argv.extend_from_slice(args);
SharedOnlyArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.prepare().unwrap().client.duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.prepare().unwrap().client.duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn multiple_positional_targets_parse() {
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
assert_eq!(specs[1].label, "host-b:2112");
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
}
#[test]
fn repeated_labeled_targets_parse() {
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "ams");
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
assert_eq!(specs[1].label, "sg");
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
}
#[test]
fn at_least_one_target_is_required() {
// The TUI has no target-free mode, so this is rejected at parse time
// rather than during preparation.
assert!(parse(&[]).is_err());
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
}
#[test]
fn duplicate_labels_are_rejected() {
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
let err = args.prepare().unwrap_err();
assert!(err.contains("duplicate target label"));
}
#[test]
fn duplicate_positional_target_strings_get_stable_suffixes() {
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
let specs = args.prepare().unwrap().targets;
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
}
#[test]
fn duplicate_target_endpoints_are_allowed() {
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
assert_eq!(args.prepare().unwrap().target_count(), 2);
}
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
}
#[test]
fn old_target_option_is_rejected() {
assert!(parse(&["--target", "eu=host.example"]).is_err());
}
#[test]
fn pacing_option_accepts_supported_values() {
assert_eq!(
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Staggered
);
assert_eq!(
parse(&["--pacing", "burst", "127.0.0.1:2112"])
.unwrap()
.pacing,
GroupPacingArg::Burst
);
}
#[test]
fn shared_client_options_match_tui_config_mapping() {
let shared = [
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
];
let tui_args = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
let common = parse_shared(&shared).unwrap().common;
let shared = common.to_client_config(Duration::from_secs(30));
assert_eq!(shared.duration, tui.duration);
assert_eq!(shared.interval, tui.interval);
assert_eq!(shared.length, tui.length);
assert_eq!(shared.received_stats, tui.received_stats);
assert_eq!(shared.stamp_at, tui.stamp_at);
assert_eq!(shared.clock, tui.clock);
assert_eq!(shared.dscp, tui.dscp);
assert_eq!(shared.hmac_key, tui.hmac_key);
assert_eq!(shared.server_fill, tui.server_fill);
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
}
#[test]
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
// CommonClientArgs is flattened into both the client and TUI parsers,
// so proving rejection once here covers both without duplicating it.
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--target "));
assert!(help.contains("--pacing <PACING>"));
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
+303
View File
@@ -0,0 +1,303 @@
use std::{
collections::HashSet,
io,
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{
is_shutdown_requested,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, ManagedDrainState,
},
},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_client::managed::{
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEventTryRecvError, TargetInstance,
};
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const INPUT_EVENT_WORK_BUDGET: usize = 128;
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let setup = args
.prepare()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::with_target_labels(
TuiConfig::from_args(&args, &setup),
setup.targets.iter().map(|target| target.label.clone()),
);
let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
) {
Ok(value) => value,
Err(error) => {
state.set_error(error.to_string());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(Box::new(error));
}
};
let handle = owner.handle();
let mut interrupted = false;
let mut stop_requested = false;
let mut terminal_targets = HashSet::new();
let mut dropped_events = 0;
let mut subscription_closed = false;
loop {
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
if request_managed_stop_for_peer_close(
continuous,
interrupted,
handle.status().peer_closed_target_outcomes,
&mut stop_requested,
) {
drop(handle.stop());
}
let drain_state = drain_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
match drain_state {
ManagedDrainState::Empty => {
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
}
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
render_if_due(&mut terminal, &state, &mut next_render, false)?;
}
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
}
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
let outcome = owner.join()?;
drain_final_tui_events(
&mut events,
&mut state,
&mut terminal_targets,
&mut dropped_events,
);
state.mark_dropped_managed_events(dropped_events);
if outcome.discarded_target_outcomes != 0 {
state.set_run_error(format!(
"{} final target outcomes were discarded",
outcome.discarded_target_outcomes
));
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
state.process_target_outcome(target);
}
}
interrupted |= is_shutdown_requested(shutdown_requested);
let error = match &outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Some(format!("managed driver failed: {failure}"))
}
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
.or_else(|| {
(!interrupted
&& outcome.successful_target_outcomes == 0
&& outcome.failed_target_outcomes > 0)
.then(|| {
format!(
"no managed target completed successfully ({} failed)",
outcome.failed_target_outcomes
)
})
}),
};
if let Some(error) = error {
state.set_run_error(error.clone());
render_if_due(&mut terminal, &state, &mut next_render, true)?;
return Err(error.into());
}
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &mut next_render, true)?;
Ok(())
}
fn process_tui_event(
event: ManagedEvent,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
) {
match event {
ManagedEvent::Client { target, event } => state.process_target_event(&target, &event),
ManagedEvent::TargetFinished { outcome } => {
terminal_targets.insert(outcome.target.clone());
state.process_target_outcome(&outcome);
}
_ => {}
}
}
fn drain_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) -> ManagedDrainState {
drain_managed_events(events, dropped_events, |event| {
process_tui_event(event, state, terminal_targets);
Ok::<(), std::convert::Infallible>(())
})
.expect("processing TUI managed events is infallible")
}
fn drain_final_tui_events(
events: &mut ManagedEventSubscription,
state: &mut TuiState,
terminal_targets: &mut HashSet<TargetInstance>,
dropped_events: &mut u64,
) {
loop {
match events.try_recv() {
Ok(event) => process_tui_event(event, state, terminal_targets),
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
for _ in 0..INPUT_EVENT_WORK_BUDGET {
if !event::poll(Duration::ZERO)? {
break;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('c')
if key.code == KeyCode::Char('q')
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
break;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.toggle_view();
force_render = true;
}
KeyCode::Char('m') => {
state.cycle_graph_metric();
force_render = true;
}
KeyCode::Left => {
state.pan_graph_left();
force_render = true;
}
KeyCode::Right => {
state.pan_graph_right();
force_render = true;
}
KeyCode::PageUp => {
state.pan_graph_page_left();
force_render = true;
}
KeyCode::PageDown => {
state.pan_graph_page_right();
force_render = true;
}
KeyCode::Home => {
state.jump_graph_oldest();
force_render = true;
}
KeyCode::End => {
state.jump_graph_live();
force_render = true;
}
KeyCode::Char('+') | KeyCode::Char('=') => {
state.zoom_graph_in();
force_render = true;
}
KeyCode::Char('-') => {
state.zoom_graph_out();
force_render = true;
}
KeyCode::Char('0') => {
state.reset_graph_window();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if should_render(now, *next_render, state.paused, force) {
terminal.draw(state)?;
*next_render = now + RENDER_INTERVAL;
}
Ok(())
}
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
let render_wait = if paused {
TUI_WAIT_SLICE
} else {
next_render.saturating_duration_since(Instant::now())
};
render_wait.min(TUI_WAIT_SLICE)
}
File diff suppressed because it is too large. Load diff