Replace the manual single-target ClientSession loops in the stream and TUI adapters with ManagedClient::start_with_subscription so the existing worker owns opening, deadline-aware pacing, receive handling, timeout polling, draining, and close. Use bounded subscription waits for interruption and input polling, drain queued events before and after join, and report per-subscription drops without changing output, statistics, labels, summaries, warning, or peer-close policy. Add process and unit regressions for 10 ms cadence, interruption, final-event draining, TUI terminal state, and overflow visibility.
967 lines
31 KiB
Rust
967 lines
31 KiB
Rust
#[cfg(feature = "stats")]
|
|
use std::collections::BTreeMap;
|
|
use std::{
|
|
collections::HashSet,
|
|
io::{self, Write},
|
|
sync::atomic::AtomicBool,
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use irtt_client::{
|
|
ClientEvent, EventSubscriptionError, ManagedClient, ManagedClientGroup,
|
|
ManagedClientGroupConfig, ManagedGroupCompletionPolicy, ManagedGroupEndReason,
|
|
ManagedGroupEvent, ManagedTargetOutcome, SessionEndReason, SubscriberConfig,
|
|
SubscriberOverflow, TargetEvent,
|
|
};
|
|
#[cfg(all(test, feature = "stats"))]
|
|
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
|
|
|
|
use super::{
|
|
args::{ClientArgs, ResolvedCliTarget},
|
|
output::{EventRenderStats, OutputConfig},
|
|
};
|
|
#[cfg(feature = "stats")]
|
|
use crate::shared::client::expected_probe_count;
|
|
use crate::shared::client::{
|
|
is_shutdown_requested,
|
|
session::{
|
|
peer_close_run_error, request_group_stop_once, should_print_final_summary,
|
|
should_stop_group_on_peer_close,
|
|
},
|
|
};
|
|
|
|
#[cfg(feature = "stats")]
|
|
use irtt_stats::{StatsCollector, StatsConfig};
|
|
|
|
#[cfg(feature = "stats")]
|
|
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
|
|
#[cfg(feature = "stats")]
|
|
const MIB: u64 = 1024 * 1024;
|
|
#[cfg(feature = "stats")]
|
|
const GIB: u64 = 1024 * MIB;
|
|
#[cfg(feature = "stats")]
|
|
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
|
#[cfg(feature = "stats")]
|
|
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
|
#[cfg(feature = "stats")]
|
|
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
|
|
|
const GROUP_IDLE_SLEEP: Duration = Duration::from_millis(5);
|
|
const GROUP_COMPLETION_GRACE: Duration = Duration::from_secs(1);
|
|
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
|
|
|
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 output_config = OutputConfig::new(
|
|
args.format,
|
|
args.columns.as_deref(),
|
|
args.header,
|
|
args.verbose,
|
|
args.target_specs()
|
|
.map(|targets| targets.len() > 1)
|
|
.unwrap_or(false),
|
|
)
|
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
|
|
|
let targets = args
|
|
.resolved_managed_targets()
|
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
|
let multi_target = targets.len() > 1;
|
|
|
|
let continuous = args.is_continuous();
|
|
#[cfg(feature = "stats")]
|
|
if let Some(warning) = finite_stats_memory_warning(&args) {
|
|
eprintln!("{warning}");
|
|
}
|
|
|
|
if multi_target {
|
|
return run_group_stream(args, targets, output_config, continuous, shutdown_requested);
|
|
}
|
|
|
|
let target_label = targets
|
|
.first()
|
|
.map(|target| target.label.as_str())
|
|
.expect("at least one target was validated");
|
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
|
#[cfg(feature = "stats")]
|
|
let mut stats = StatsCollector::new(stats_config(continuous));
|
|
let mut stream_output = StreamOutput {
|
|
config: output_config,
|
|
header_printed: false,
|
|
print_final_summary: false,
|
|
show_running_only_summary_note: false,
|
|
out: &mut stdout,
|
|
};
|
|
|
|
if is_shutdown_requested(shutdown_requested) {
|
|
return Ok(());
|
|
}
|
|
|
|
let (session, events) = ManagedClient::start_with_subscription(
|
|
args.to_client_config(),
|
|
managed_event_subscriber_config(),
|
|
)?;
|
|
let mut interrupted = false;
|
|
loop {
|
|
if is_shutdown_requested(shutdown_requested) {
|
|
interrupted = true;
|
|
session.stop();
|
|
break;
|
|
}
|
|
|
|
match events.recv_timeout(MANAGED_EVENT_WAIT_SLICE) {
|
|
Ok(Some(event)) => {
|
|
#[cfg(feature = "stats")]
|
|
print_events_with_stats(
|
|
&mut stream_output,
|
|
std::slice::from_ref(&event),
|
|
Some(target_label),
|
|
&mut stats,
|
|
)?;
|
|
#[cfg(not(feature = "stats"))]
|
|
print_events_with_stats(
|
|
&mut stream_output,
|
|
std::slice::from_ref(&event),
|
|
Some(target_label),
|
|
)?;
|
|
}
|
|
Ok(None) => {}
|
|
Err(EventSubscriptionError::Disconnected) => break,
|
|
}
|
|
}
|
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
|
|
if interrupted {
|
|
eprintln!("interrupted, closing session...");
|
|
drain_single_client_events(
|
|
&events,
|
|
&mut stream_output,
|
|
target_label,
|
|
#[cfg(feature = "stats")]
|
|
&mut stats,
|
|
)?;
|
|
}
|
|
|
|
let outcome = session.join();
|
|
drain_single_client_events(
|
|
&events,
|
|
&mut stream_output,
|
|
target_label,
|
|
#[cfg(feature = "stats")]
|
|
&mut stats,
|
|
)?;
|
|
if let Some(warning) = dropped_event_warning(events.dropped_events(), "managed client") {
|
|
eprintln!("{warning}");
|
|
}
|
|
|
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
let peer_close_error = outcome.as_ref().ok().and_then(|outcome| {
|
|
peer_close_run_error(
|
|
continuous,
|
|
interrupted,
|
|
u64::from(outcome.end_reason == SessionEndReason::PeerClosed),
|
|
)
|
|
});
|
|
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
|
stream_output.print_final_summary = print_final_summary;
|
|
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
|
#[cfg(feature = "stats")]
|
|
stream_output.print_summary(&stats)?;
|
|
#[cfg(not(feature = "stats"))]
|
|
stream_output.print_summary()?;
|
|
stream_output.out.flush()?;
|
|
if let Err(error) = outcome {
|
|
return Err(error.into());
|
|
}
|
|
if let Some(error) = peer_close_error {
|
|
return Err(error.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn managed_event_subscriber_config() -> SubscriberConfig {
|
|
SubscriberConfig {
|
|
capacity: MANAGED_EVENT_CAPACITY,
|
|
// CLI output is best-effort under sustained backpressure. Dropping
|
|
// stale rows keeps the managed worker independent from output speed,
|
|
// while dropped_events() makes incomplete output visible.
|
|
overflow: SubscriberOverflow::DropOldest,
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn drain_single_client_events<W: Write>(
|
|
events: &irtt_client::EventSubscription,
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
target_label: &str,
|
|
stats: &mut StatsCollector,
|
|
) -> io::Result<()> {
|
|
while let Ok(Some(event)) = events.try_recv() {
|
|
print_events_with_stats(
|
|
stream_output,
|
|
std::slice::from_ref(&event),
|
|
Some(target_label),
|
|
stats,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(feature = "stats"))]
|
|
fn drain_single_client_events<W: Write>(
|
|
events: &irtt_client::EventSubscription,
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
target_label: &str,
|
|
) -> io::Result<()> {
|
|
while let Ok(Some(event)) = events.try_recv() {
|
|
print_events_with_stats(
|
|
stream_output,
|
|
std::slice::from_ref(&event),
|
|
Some(target_label),
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
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) {
|
|
self.print_event(event, target, stats_update)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_event(
|
|
&mut self,
|
|
event: &ClientEvent,
|
|
target: Option<&str>,
|
|
stats_update: &EventRenderStats,
|
|
) -> io::Result<()> {
|
|
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(())
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
|
if !self.print_final_summary || !self.config.prints_summary() {
|
|
return Ok(());
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
#[cfg(not(feature = "stats"))]
|
|
fn print_summary(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn print_events_with_stats<W: Write>(
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
events: &[ClientEvent],
|
|
target: Option<&str>,
|
|
stats: &mut StatsCollector,
|
|
) -> io::Result<()> {
|
|
let stats_updates = events
|
|
.iter()
|
|
.map(|event| EventRenderStats::from(stats.process(event)))
|
|
.collect::<Vec<_>>();
|
|
stream_output.print_events(events, target, &stats_updates)
|
|
}
|
|
|
|
#[cfg(not(feature = "stats"))]
|
|
fn print_events_with_stats<W: Write>(
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
events: &[ClientEvent],
|
|
target: Option<&str>,
|
|
) -> io::Result<()> {
|
|
let stats_updates = vec![EventRenderStats::default(); events.len()];
|
|
stream_output.print_events(events, target, &stats_updates)
|
|
}
|
|
|
|
fn run_group_stream(
|
|
args: ClientArgs,
|
|
targets: Vec<ResolvedCliTarget>,
|
|
output_config: OutputConfig,
|
|
continuous: bool,
|
|
shutdown_requested: &AtomicBool,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut config = args.to_client_config();
|
|
let managed_targets = targets
|
|
.iter()
|
|
.map(|target| target.managed.clone())
|
|
.collect::<Vec<_>>();
|
|
let expected_target_count = managed_targets.len();
|
|
if let Some(first) = managed_targets.first() {
|
|
config.server_addr = first.remote.to_string();
|
|
}
|
|
|
|
let group_config = ManagedClientGroupConfig {
|
|
client: config,
|
|
pacing: args.pacing.into(),
|
|
completion: ManagedGroupCompletionPolicy::AllTargetsComplete,
|
|
};
|
|
|
|
let (session, events) = ManagedClientGroup::start_with_subscription(
|
|
group_config,
|
|
managed_targets,
|
|
SubscriberConfig {
|
|
capacity: MANAGED_EVENT_CAPACITY,
|
|
// CLI output is best-effort under sustained backpressure. Dropping
|
|
// stale rows keeps continuous runs attached to the running group
|
|
// instead of turning a full output queue into a disconnected
|
|
// subscriber and a potentially blocking join.
|
|
overflow: SubscriberOverflow::DropOldest,
|
|
},
|
|
)?;
|
|
|
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
|
#[cfg(feature = "stats")]
|
|
let mut stats = targets
|
|
.iter()
|
|
.map(|target| {
|
|
(
|
|
target.label.clone(),
|
|
StatsCollector::new(stats_config(continuous)),
|
|
)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
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 interrupted = false;
|
|
let mut stop_requested = false;
|
|
let mut peer_close_requested_stop = false;
|
|
let mut terminal_targets = HashSet::new();
|
|
let mut last_event_at = Instant::now();
|
|
let mut saw_target_event = false;
|
|
|
|
loop {
|
|
if is_shutdown_requested(shutdown_requested) {
|
|
interrupted = true;
|
|
if request_group_stop_once(&mut stop_requested) {
|
|
session.stop();
|
|
}
|
|
}
|
|
|
|
match events.try_recv() {
|
|
Ok(Some(group_event)) => {
|
|
last_event_at = Instant::now();
|
|
saw_target_event = true;
|
|
match group_event {
|
|
ManagedGroupEvent::Client(target_event) => {
|
|
#[cfg(feature = "stats")]
|
|
{
|
|
let label = target_event.target.as_str();
|
|
let stats = stats
|
|
.entry(label.to_owned())
|
|
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
|
|
print_target_event_with_stats(
|
|
&mut stream_output,
|
|
&target_event,
|
|
stats,
|
|
)?;
|
|
}
|
|
#[cfg(not(feature = "stats"))]
|
|
{
|
|
print_target_event_with_stats(&mut stream_output, &target_event)?;
|
|
}
|
|
}
|
|
ManagedGroupEvent::TargetFinished(target) => {
|
|
report_target_failure(&target);
|
|
terminal_targets.insert(target.id.as_str().to_owned());
|
|
if should_stop_group_on_peer_close(
|
|
continuous,
|
|
interrupted,
|
|
&target.end_reason,
|
|
) {
|
|
peer_close_requested_stop = true;
|
|
if request_group_stop_once(&mut stop_requested) {
|
|
session.stop();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
if interrupted {
|
|
break;
|
|
}
|
|
if terminal_targets.len() >= expected_target_count {
|
|
break;
|
|
}
|
|
if should_join_group_after_idle(&args, continuous, saw_target_event, last_event_at)
|
|
{
|
|
break;
|
|
}
|
|
thread::sleep(GROUP_IDLE_SLEEP);
|
|
}
|
|
Err(EventSubscriptionError::Disconnected) => break,
|
|
}
|
|
}
|
|
|
|
if interrupted {
|
|
eprintln!("interrupted, closing group...");
|
|
}
|
|
|
|
let outcome = session.join()?;
|
|
while let Ok(Some(group_event)) = events.try_recv() {
|
|
match group_event {
|
|
ManagedGroupEvent::Client(target_event) => {
|
|
#[cfg(feature = "stats")]
|
|
{
|
|
let label = target_event.target.as_str();
|
|
let stats = stats
|
|
.entry(label.to_owned())
|
|
.or_insert_with(|| StatsCollector::new(stats_config(continuous)));
|
|
print_target_event_with_stats(&mut stream_output, &target_event, stats)?;
|
|
}
|
|
#[cfg(not(feature = "stats"))]
|
|
{
|
|
print_target_event_with_stats(&mut stream_output, &target_event)?;
|
|
}
|
|
}
|
|
ManagedGroupEvent::TargetFinished(target) => {
|
|
if terminal_targets.insert(target.id.as_str().to_owned()) {
|
|
report_target_failure(&target);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(warning) = dropped_event_warning(events.dropped_events(), "managed group") {
|
|
eprintln!("{warning}");
|
|
}
|
|
|
|
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
for target in &outcome.targets {
|
|
if terminal_targets.insert(target.id.as_str().to_owned()) {
|
|
report_target_failure(target);
|
|
}
|
|
}
|
|
let successful_targets = outcome.successful_target_outcomes;
|
|
let failed_targets = outcome.failed_target_outcomes;
|
|
let peer_closed_target_outcomes = outcome
|
|
.peer_closed_target_outcomes
|
|
.max(u64::from(peer_close_requested_stop));
|
|
let terminal_error = if let Some(error) =
|
|
peer_close_run_error(continuous, interrupted, peer_closed_target_outcomes)
|
|
{
|
|
Some(error)
|
|
} else if outcome.end_reason == ManagedGroupEndReason::Cancelled
|
|
&& !interrupted
|
|
&& !peer_close_requested_stop
|
|
{
|
|
Some("managed client group was cancelled".to_owned())
|
|
} else if !interrupted && successful_targets == 0 && failed_targets > 0 {
|
|
Some(format!(
|
|
"no managed target completed successfully ({failed_targets} failed)"
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let print_final_summary = should_print_final_summary(continuous, interrupted);
|
|
stream_output.print_final_summary = print_final_summary;
|
|
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
|
|
#[cfg(feature = "stats")]
|
|
{
|
|
for (label, stats) in &stats {
|
|
if stream_output.print_final_summary && stream_output.config.prints_summary() {
|
|
writeln!(stream_output.out)?;
|
|
writeln!(stream_output.out, "target: {label}")?;
|
|
}
|
|
stream_output.print_summary(stats)?;
|
|
}
|
|
}
|
|
#[cfg(not(feature = "stats"))]
|
|
{
|
|
stream_output.print_summary()?;
|
|
}
|
|
stream_output.out.flush()?;
|
|
if let Some(error) = terminal_error {
|
|
return Err(error.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn report_target_failure(target: &ManagedTargetOutcome) {
|
|
if let Some(failure) = target.end_reason.failure() {
|
|
eprintln!(
|
|
"irtt-rs: target {} failed ({}): {}",
|
|
target.id, failure.kind, failure.message
|
|
);
|
|
}
|
|
}
|
|
|
|
fn dropped_event_warning(dropped_events: u64, source: &str) -> Option<String> {
|
|
(dropped_events > 0).then(|| {
|
|
let event_word = if dropped_events == 1 {
|
|
"event"
|
|
} else {
|
|
"events"
|
|
};
|
|
format!(
|
|
"irtt-rs: warning: dropped {dropped_events} {source} {event_word}; output and statistics may be incomplete"
|
|
)
|
|
})
|
|
}
|
|
|
|
fn estimated_group_completion_grace(args: &ClientArgs) -> Duration {
|
|
let open_timeout: Duration = args.to_client_config().open_timeouts.iter().sum();
|
|
open_timeout
|
|
.saturating_add(args.duration)
|
|
.saturating_add(GROUP_COMPLETION_GRACE)
|
|
}
|
|
|
|
fn should_join_group_after_idle(
|
|
args: &ClientArgs,
|
|
continuous: bool,
|
|
saw_target_event: bool,
|
|
last_event_at: Instant,
|
|
) -> bool {
|
|
if continuous && saw_target_event {
|
|
return false;
|
|
}
|
|
last_event_at.elapsed() > estimated_group_completion_grace(args)
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn print_target_event_with_stats<W: Write>(
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
target_event: &TargetEvent,
|
|
stats: &mut StatsCollector,
|
|
) -> io::Result<()> {
|
|
print_events_with_stats(
|
|
stream_output,
|
|
std::slice::from_ref(&target_event.event),
|
|
Some(target_event.target.as_str()),
|
|
stats,
|
|
)
|
|
}
|
|
|
|
#[cfg(not(feature = "stats"))]
|
|
fn print_target_event_with_stats<W: Write>(
|
|
stream_output: &mut StreamOutput<'_, W>,
|
|
target_event: &TargetEvent,
|
|
) -> io::Result<()> {
|
|
print_events_with_stats(
|
|
stream_output,
|
|
std::slice::from_ref(&target_event.event),
|
|
Some(target_event.target.as_str()),
|
|
)
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn stats_config(continuous: bool) -> StatsConfig {
|
|
if continuous {
|
|
StatsConfig::continuous()
|
|
} else {
|
|
StatsConfig::finite()
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
|
|
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
|
|
}
|
|
|
|
#[cfg(feature = "stats")]
|
|
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
|
|
if args.is_continuous() || args.duration.is_zero() {
|
|
return None;
|
|
}
|
|
|
|
let expected_probes = expected_probe_count(args.duration, args.interval);
|
|
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
|
|
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
|
return None;
|
|
}
|
|
|
|
let formatted = format_bytes_for_warning(estimated_bytes);
|
|
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(feature = "stats")]
|
|
fn format_bytes_for_warning(bytes: u64) -> String {
|
|
if bytes >= GIB {
|
|
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
|
|
} else {
|
|
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, feature = "stats"))]
|
|
mod tests {
|
|
use super::*;
|
|
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)
|
|
}
|
|
|
|
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
|
|
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
|
|
let received_at = ClientTimestamp {
|
|
wall: sent_at.wall + Duration::from_micros(rtt_us),
|
|
mono: sent_at.mono + Duration::from_micros(rtt_us),
|
|
};
|
|
ClientEvent::EchoReply {
|
|
seq,
|
|
remote: test_remote(),
|
|
sent_at,
|
|
received_at,
|
|
rtt: RttSample {
|
|
raw: Duration::from_micros(rtt_us),
|
|
adjusted: None,
|
|
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
|
|
},
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: None,
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
}
|
|
}
|
|
|
|
fn cli_args(args: &[&str]) -> ClientArgs {
|
|
let mut argv = vec!["irtt-rs"];
|
|
argv.extend_from_slice(args);
|
|
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
|
|
}
|
|
|
|
fn output_config(
|
|
format: crate::cmd::client::OutputFormat,
|
|
columns: Option<&str>,
|
|
header: crate::cmd::client::HeaderMode,
|
|
verbose: bool,
|
|
) -> OutputConfig {
|
|
OutputConfig::new(format, columns, header, verbose, false).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn dropped_event_warning_marks_statistics_incomplete() {
|
|
assert_eq!(dropped_event_warning(0, "managed client"), None);
|
|
let warning = dropped_event_warning(7, "managed group").unwrap();
|
|
assert!(warning.contains("dropped 7 managed group events"));
|
|
assert!(warning.contains("statistics may be incomplete"));
|
|
}
|
|
|
|
#[test]
|
|
fn single_client_drain_preserves_final_queue_and_surfaces_overflow() {
|
|
let hub = irtt_client::EventHub::new();
|
|
let events = hub
|
|
.subscribe(SubscriberConfig {
|
|
capacity: 1,
|
|
overflow: SubscriberOverflow::DropOldest,
|
|
})
|
|
.unwrap();
|
|
hub.publish(reply_event(1, 1_000));
|
|
hub.publish(reply_event(2, 2_000));
|
|
hub.disconnect_all();
|
|
|
|
let mut stats = StatsCollector::new(StatsConfig::finite());
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut stream_output = StreamOutput {
|
|
config: output_config(
|
|
crate::cmd::client::OutputFormat::Csv,
|
|
Some("event,seq"),
|
|
crate::cmd::client::HeaderMode::Never,
|
|
false,
|
|
),
|
|
header_printed: false,
|
|
print_final_summary: false,
|
|
show_running_only_summary_note: false,
|
|
out: &mut out,
|
|
};
|
|
drain_single_client_events(&events, &mut stream_output, "target", &mut stats).unwrap();
|
|
}
|
|
|
|
let rendered = String::from_utf8(out).unwrap();
|
|
assert_eq!(rendered.trim(), "echo_reply,2");
|
|
assert_eq!(events.dropped_events(), 1);
|
|
assert!(
|
|
dropped_event_warning(events.dropped_events(), "managed client")
|
|
.unwrap()
|
|
.contains("dropped 1 managed client event")
|
|
);
|
|
assert_eq!(
|
|
events.try_recv().unwrap_err(),
|
|
EventSubscriptionError::Disconnected
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn output_helper_streams_and_collects_events() {
|
|
let sent_at = test_timestamp(Duration::from_secs(1));
|
|
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
|
|
let events = [
|
|
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::ZERO,
|
|
},
|
|
ClientEvent::EchoReply {
|
|
seq: 1,
|
|
remote: test_remote(),
|
|
sent_at,
|
|
received_at,
|
|
rtt: RttSample {
|
|
raw: Duration::from_micros(1200),
|
|
adjusted: None,
|
|
effective: SignedDuration::from_nanos(1_200_000),
|
|
},
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: None,
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
},
|
|
];
|
|
|
|
let mut stats = StatsCollector::new(StatsConfig::finite());
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut stream_output = StreamOutput {
|
|
config: output_config(
|
|
crate::cmd::client::OutputFormat::Tsv,
|
|
Some("effective_rtt_us"),
|
|
crate::cmd::client::HeaderMode::Never,
|
|
false,
|
|
),
|
|
header_printed: false,
|
|
print_final_summary: true,
|
|
show_running_only_summary_note: false,
|
|
out: &mut out,
|
|
};
|
|
print_events_with_stats(&mut stream_output, &events, None, &mut stats).unwrap();
|
|
stream_output.print_summary(&stats).unwrap();
|
|
}
|
|
|
|
let rendered = String::from_utf8(out).unwrap();
|
|
let summary = stats.snapshot();
|
|
assert!(!rendered.is_empty());
|
|
assert_eq!(summary.packets.packets_sent, 1);
|
|
assert_eq!(summary.packets.unique_replies, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
|
|
let mut stats = StatsCollector::new(StatsConfig::continuous());
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut stream_output = StreamOutput {
|
|
config: output_config(
|
|
crate::cmd::client::OutputFormat::Table,
|
|
None,
|
|
crate::cmd::client::HeaderMode::Auto,
|
|
false,
|
|
),
|
|
header_printed: false,
|
|
print_final_summary: true,
|
|
show_running_only_summary_note: true,
|
|
out: &mut out,
|
|
};
|
|
print_events_with_stats(
|
|
&mut stream_output,
|
|
&[reply_event(1, 1200)],
|
|
None,
|
|
&mut stats,
|
|
)
|
|
.unwrap();
|
|
stream_output.print_summary(&stats).unwrap();
|
|
}
|
|
|
|
let rendered = String::from_utf8(out).unwrap();
|
|
assert!(rendered.contains("irtt-rs summary"));
|
|
assert!(rendered.contains("medians unavailable"));
|
|
assert!(rendered.contains("continuous mode"));
|
|
assert!(rendered.contains("packets:"));
|
|
assert!(rendered.contains("received=1"));
|
|
|
|
let stats = StatsCollector::new(StatsConfig::continuous());
|
|
let mut out = Vec::new();
|
|
{
|
|
let mut stream_output = StreamOutput {
|
|
config: output_config(
|
|
crate::cmd::client::OutputFormat::Table,
|
|
None,
|
|
crate::cmd::client::HeaderMode::Auto,
|
|
false,
|
|
),
|
|
header_printed: false,
|
|
print_final_summary: false,
|
|
show_running_only_summary_note: true,
|
|
out: &mut out,
|
|
};
|
|
stream_output.print_summary(&stats).unwrap();
|
|
}
|
|
|
|
assert!(out.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn continuous_mode_uses_continuous_stats_config() {
|
|
let mut collector = StatsCollector::new(stats_config(true));
|
|
for seq in 0..5000_u32 {
|
|
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
|
|
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
|
|
collector.process(&ClientEvent::EchoReply {
|
|
seq,
|
|
remote: test_remote(),
|
|
sent_at,
|
|
received_at,
|
|
rtt: RttSample {
|
|
raw: Duration::from_micros(1),
|
|
adjusted: None,
|
|
effective: SignedDuration::from_nanos(1_000),
|
|
},
|
|
server_timing: None,
|
|
one_way: None,
|
|
received_stats: None,
|
|
bytes: 64,
|
|
packet_meta: PacketMeta::default(),
|
|
});
|
|
}
|
|
|
|
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
|
|
}
|
|
|
|
#[test]
|
|
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
|
|
for args in [
|
|
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
|
|
cli_args(&[
|
|
"--duration",
|
|
"1000ms",
|
|
"--interval",
|
|
"1ms",
|
|
"127.0.0.1:2112",
|
|
]),
|
|
] {
|
|
assert_eq!(finite_stats_memory_warning(&args), None);
|
|
}
|
|
|
|
for (threshold, size, guidance) in [
|
|
(
|
|
FINITE_STATS_MEMORY_WARNING_BYTES,
|
|
"about 128 MiB",
|
|
"bounded-memory long-running tests",
|
|
),
|
|
(
|
|
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
|
|
"about 512 MiB",
|
|
"shortening the run",
|
|
),
|
|
(
|
|
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
|
|
"about 1 GiB",
|
|
"memory-constrained systems",
|
|
),
|
|
] {
|
|
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
|
|
let args = cli_args(&[
|
|
"--duration",
|
|
&format!("{expected_probes}ms"),
|
|
"--interval",
|
|
"1ms",
|
|
"127.0.0.1:2112",
|
|
]);
|
|
|
|
let warning = finite_stats_memory_warning(&args).unwrap();
|
|
assert!(warning.contains(size));
|
|
assert!(warning.contains(guidance));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expected_probe_count_rounds_up() {
|
|
assert_eq!(
|
|
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
|
|
2
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn estimate_finite_stats_memory_bytes_saturates() {
|
|
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
|
|
}
|
|
}
|