Compare commits
7
Commits
c0a4f7a6db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e912632d | ||
|
|
39cbf7b6b5 | ||
|
|
eaa37d7568 | ||
|
|
da7472e10a | ||
|
|
9e6c10c083 | ||
|
|
988a360fef | ||
|
|
c28e63e67a |
No files matched your search
@@ -34,6 +34,37 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-client
|
||||
|
||||
### 0.6.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Added `ManagedClientHandle::subscribe_status()` and `ManagedStatusSubscription` for observing durable lifecycle and completion state independently of the lossy event stream. The current snapshot is immediately readable, change notifications track subsequent updates, and terminal target status includes its completed outcome.
|
||||
- Managed targets can override the shared address-family policy while retaining the original hostname for resolution on each new generation.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `ClientConfig` is now reusable across endpoints: `Client::connect` and `AsyncClient::connect` take the endpoint separately, with configuration grouped into `socket`, `request` (`SessionRequest`), and `open` (`OpenPolicy`). `AddressFamily` replaces the separate socket family flags, and blocking receive timeouts are configured through `Client::set_recv_timeout`.
|
||||
- Explicit `Authentication`, `HmacKey`, and managed `TargetAuth` replace `ClientConfig.hmac_key` and `ClientAuthConfig`. Key bytes are shared across clones and redacted from debug output. Switching between inherited and explicit authentication or address-family settings starts a new target generation, even when the effective settings are equal.
|
||||
- `NegotiationResult` replaces `NegotiatedParams`, separating typed `AcceptedSessionParameters` from exact peer-returned wire parameters and accepted `NegotiationChange` records (formerly `NegotiationRestriction`). Open outcomes and lifecycle events now use tuple variants carrying `SessionStarted` or `NoTestCompleted` payloads.
|
||||
- Low-level clients now leave probe cadence and run duration entirely to the caller. `next_send_deadline` and `is_run_complete` were removed; managed clients continue to provide scheduling and finite-run completion. Low-level callers can inspect `negotiation()`, `has_pending_probes()`, and `next_probe_timeout_deadline()` to drive their own event loop.
|
||||
- Probe sends now return `Result<SendReceipt, SendProbeError>`, distinguishing failures before socket acceptance from failures after a probe was committed. Receipts convert to `ClientEvent::EchoSent`; its `scheduled_at` and `timer_error` fields are now optional and absent for low-level sends.
|
||||
- Stop receipts resolve when the stop request is durably observed or the task becomes terminal; callers must still await task completion to observe finished cleanup.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Finite managed runs now reach their negotiated end under socket backpressure, even when no pending probe timeout remains to wake the driver. Staggered pacing no longer repeatedly wakes idle or draining targets that have no send deadline.
|
||||
- Blocking receive operations now return lifecycle errors immediately when no session is open, instead of blocking for a datagram or returning empty success after a timeout.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Managed drain-deadline lookup no longer scans retained timed-out probes, keeping lookup work bounded as retained probe state grows.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- The configuration, authentication, negotiation, lifecycle-event, and send APIs above are breaking changes without compatibility aliases. Low-level callers must enforce their own cadence and duration or use a managed client.
|
||||
- `AcceptedSessionParameters::dscp` is a six-bit codepoint; `NegotiationResult::peer_params.dscp` remains the raw traffic-class byte. Accepted durations and intervals use Rust duration types rather than wire nanosecond integers.
|
||||
- Tokio remains optional, and the default client build remains runtime-free. These API changes do not introduce a wire-protocol migration.
|
||||
|
||||
### 0.5.3
|
||||
|
||||
#### Added
|
||||
@@ -119,6 +150,29 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-stats
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Changed
|
||||
|
||||
- `TimeStats` is now re-exported from `measurement-stats`, retaining the existing `irtt_stats::TimeStats` import path, fields, and methods.
|
||||
- Timer-error statistics now omit sends without scheduling metadata instead of recording an artificial measurement.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Time-based rolling windows now expire backdated events correctly, including expired events inside retained arrival history. Older timestamps cannot move the expiry anchor backwards or restore expired history.
|
||||
- Rolling upstream and downstream loss now use the server receive-count increase over the retained packet-event interval instead of comparing window counts against a cumulative server count.
|
||||
- Rolling directional-loss estimates are unavailable when counter observations are missing, stale, discontinuous, or separated by an interior gap in the time window, rather than reporting misleading values.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Indexed rolling-window expiry avoids full-window scans on insertion and reclaims expired entries without accumulating tombstones.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- This release consumes `irtt-client` 0.6 events; applications using both crates must upgrade them together.
|
||||
- Unavailable rolling directional-loss packet estimates are `None`; their percentage fields remain `0.0`. Valid signed estimates remain possible, and raw server receive counts retain their cumulative meaning.
|
||||
- Time-based rolling windows expire when new events arrive, retain events exactly at the cutoff, and have no event-count cap. Use `rolling_count` when event storage needs a fixed bound.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
@@ -138,6 +192,44 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-rs
|
||||
|
||||
### 0.8.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Added mutually exclusive `-4` / `--ipv4`, `-6` / `--ipv6`, and `--dual-stack` options to the client and TUI. Dual-stack hostnames expand into independent `/v4` and `/v6` targets with separate sessions, statistics, and failures; explicit IP literals remain single targets.
|
||||
- Continuous TUI runs now reconnect independently after target failures, peer closure, or server-limited session completion while healthy targets continue uninterrupted. The TUI remains running even when every target fails.
|
||||
- Added target selection and a scrollable details sheet showing negotiation, counters, timing statistics, warnings, and recent events.
|
||||
- Graph axes now display local wall-clock timestamps, including fractional seconds or dates where appropriate.
|
||||
|
||||
#### Changed
|
||||
|
||||
- Replaced separate Graph and Dashboard views with one dashboard comparing all targets. Target rows show effective RTT, cumulative loss, jitter, and the age of the last primary reply. `Tab` / `Shift-Tab` selects a target; `d` opens details, with `g` retained as an alias.
|
||||
- Reconnecting targets retain their logical row and graph history, reset session statistics for each new generation, and leave gaps between generations.
|
||||
- Increased graph retention from 100,000 to 500,000 samples per target and extended the maximum graph window from one hour to 24 hours.
|
||||
- Clearing graph history now clears all targets and returns to live view while preserving latest samples and statistics.
|
||||
- Client and TUI frontends now react asynchronously to measurements, status changes, input, and shutdown, removing periodic polling delays.
|
||||
- The application library's `run_stream`, `run_tui`, and `prepare_managed_run` entry points are now asynchronous.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Graph rendering selects samples within the visible viewport instead of scanning the full retained history, with interpolation at viewport boundaries.
|
||||
- Reduced graph-history memory usage per sample substantially, allowing retention to increase from 100,000 to 500,000 samples per target without proportional memory growth.
|
||||
- Increased regular TUI refresh frequency to 100 ms for smoother updates.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- Automatic reconnect applies to continuous TUI runs, not ordinary `irtt-client` runs or finite TUI runs. Explicit stops and no-test completion do not trigger retries.
|
||||
- Dual-stack expansion changes target labels and can double probe traffic per hostname. The stdin limit of 128 desired targets applies after expansion; generated labels must remain unique.
|
||||
- Address families are discovered when a declaration is added. Each new session resolves the original hostname within its assigned family; newly appearing families are not added automatically. Unchanged stdin declarations retain their discovered families, while removal and re-addition performs fresh discovery.
|
||||
- New stdin declarations are prepared asynchronously while existing measurements continue. Preparation errors stop the stream gracefully without applying a partial target set.
|
||||
- The larger graph cap permits higher eventual memory use: approximately 42 MiB per target for retained graph samples at the cap on 64-bit macOS, excluding statistics and rendering buffers.
|
||||
- Application-library callers must await client/TUI run and preparation functions and supply the new shutdown argument to the run functions.
|
||||
- `ClientArgs::prepare` and `TuiArgs::prepare` are also asynchronous; `TargetPreparation::prepare` replaces `prepare_managed_targets`, and `parse_stdin_target_set` now returns `TargetSpec` values for preparation.
|
||||
- The application's former `TargetAuth` type was replaced by `irtt_client::managed::TargetAuth`.
|
||||
- The public output wrappers `EventRenderStats` and `IpdvPair` were removed in favor of `irtt_stats::EventStatsUpdate` and `IpdvPairUpdate`.
|
||||
- `DEFAULT_RECV_TIMEOUT`, `is_shutdown_requested`, and `TuiArgs::timestamp_mode` were removed. `install_signal_handler` is now available only with the `server` feature; client/TUI embedding uses `install_async_signal_handler`.
|
||||
- MSRV remains Rust 1.88. Existing output column names and formats are preserved.
|
||||
|
||||
### 0.7.1
|
||||
|
||||
#### Fixed
|
||||
|
||||
Generated
+15
-15
@@ -413,9 +413,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -533,9 +533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -782,7 +782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.5.3"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
@@ -815,7 +815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -845,7 +845,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-stats"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"irtt-client",
|
||||
"measurement-stats",
|
||||
@@ -1114,9 +1114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
@@ -1312,9 +1312,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
@@ -2302,18 +2302,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.59"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
||||
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.59"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
||||
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||
irtt-client = { version = "0.6.0", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.1"
|
||||
version = "0.8.0"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -57,7 +57,7 @@ irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.30.2", optional = true }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "time", "sync", "macros"], optional = true }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
|
||||
@@ -92,7 +92,7 @@ impl ClientArgs {
|
||||
///
|
||||
/// 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> {
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
}
|
||||
@@ -105,6 +105,7 @@ impl ClientArgs {
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
use irtt_client::{
|
||||
managed::{
|
||||
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
|
||||
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
@@ -22,7 +22,7 @@ use crate::shared::client::{
|
||||
request_managed_stop_once, should_print_final_summary,
|
||||
},
|
||||
worker::ManagedWorker,
|
||||
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
|
||||
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
|
||||
@@ -46,9 +46,10 @@ pub async fn run_stream(
|
||||
print!("{}", OutputConfig::list_columns());
|
||||
return Ok(());
|
||||
}
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let setup = tokio::select! {
|
||||
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||
};
|
||||
let multi_target = setup.is_multi_target();
|
||||
let output_config = OutputConfig::new(
|
||||
args.format,
|
||||
@@ -191,7 +192,7 @@ pub async fn run_stream(
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StdinUpdate {
|
||||
Targets(Vec<ManagedTargetConfig>),
|
||||
Targets(Vec<TargetSpec>),
|
||||
Stop(StdinStop),
|
||||
}
|
||||
|
||||
@@ -286,9 +287,7 @@ fn read_stdin_target_sets<R: BufRead>(
|
||||
}
|
||||
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||
Ok(targets) => {
|
||||
updates.send_replace(StdinUpdate::Targets(
|
||||
targets.into_iter().map(|target| target.managed).collect(),
|
||||
));
|
||||
updates.send_replace(StdinUpdate::Targets(targets));
|
||||
}
|
||||
Err(error) => {
|
||||
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||
@@ -449,6 +448,9 @@ async fn run_stdin_stream(
|
||||
let mut stats = BTreeMap::new();
|
||||
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||
let mut stdin_changed = false;
|
||||
let mut preparation = setup.preparation.clone();
|
||||
let mut desired_specs = None;
|
||||
let mut preparing = None;
|
||||
let mut desired = None;
|
||||
let mut pending = None;
|
||||
let mut submitted = Vec::new();
|
||||
@@ -465,13 +467,25 @@ async fn run_stdin_stream(
|
||||
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
|
||||
stdin_changed = false;
|
||||
match stdin.borrow_and_update().clone() {
|
||||
StdinUpdate::Targets(targets) => {
|
||||
desired = Some(targets);
|
||||
StdinUpdate::Targets(specs) => {
|
||||
desired_specs = Some(specs);
|
||||
desired = None;
|
||||
retry_after = None;
|
||||
}
|
||||
StdinUpdate::Stop(reason) => stop = Some(reason),
|
||||
}
|
||||
}
|
||||
if stop.is_none() && preparing.is_none() {
|
||||
if let Some(specs) = desired_specs.take() {
|
||||
// Only one preparation runs at a time. Newer records supersede
|
||||
// its result without piling up uncancellable system DNS jobs.
|
||||
preparing = Some(Box::pin(
|
||||
preparation
|
||||
.clone()
|
||||
.prepare(specs, Some(STDIN_MAX_DESIRED_TARGETS)),
|
||||
));
|
||||
}
|
||||
}
|
||||
if stop.is_none()
|
||||
&& pending.is_none()
|
||||
&& !retry_after
|
||||
@@ -494,6 +508,7 @@ async fn run_stdin_stream(
|
||||
}
|
||||
}
|
||||
if stop.is_some() {
|
||||
preparing = None;
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
@@ -508,6 +523,25 @@ async fn run_stdin_stream(
|
||||
_ = shutdown.changed(), if stop.is_none() => {}
|
||||
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
|
||||
changed = status.changed() => { if changed.is_err() { break; } }
|
||||
result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
|
||||
preparing = None;
|
||||
// Let the next loop consume any newer record, including EOF,
|
||||
// before considering this prepared revision for submission.
|
||||
if desired_specs.is_none() && !stdin.has_changed().unwrap_or(true) {
|
||||
match result {
|
||||
Ok((targets, next_preparation)) => {
|
||||
preparation = next_preparation;
|
||||
desired = Some(targets.into_iter().map(|target| target.managed).collect());
|
||||
retry_after = None;
|
||||
}
|
||||
Err(error) => {
|
||||
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
|
||||
pending = None;
|
||||
match result {
|
||||
@@ -515,7 +549,7 @@ async fn run_stdin_stream(
|
||||
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||
// A newer desired set supersedes this rejected set. Otherwise,
|
||||
// retry only after the driver's status advances from submission.
|
||||
if desired.is_none() {
|
||||
if desired.is_none() && desired_specs.is_none() && preparing.is_none() {
|
||||
desired = Some(std::mem::take(&mut submitted));
|
||||
} else {
|
||||
retry_after = None;
|
||||
|
||||
@@ -49,7 +49,7 @@ impl TuiArgs {
|
||||
///
|
||||
/// 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> {
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
@@ -59,6 +59,7 @@ impl TuiArgs {
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use std::{collections::HashSet, future::poll_fn, io, time::Duration};
|
||||
use std::{future::poll_fn, io, time::Duration};
|
||||
|
||||
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||
use crate::{
|
||||
cmd::tui::args::TuiArgs,
|
||||
shared::client::{
|
||||
session::{
|
||||
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once,
|
||||
},
|
||||
session::{drain_final_events, request_managed_stop_once},
|
||||
worker::ManagedWorker,
|
||||
},
|
||||
};
|
||||
@@ -17,7 +14,10 @@ use crossterm::{
|
||||
};
|
||||
use futures_core::Stream;
|
||||
use irtt_client::{
|
||||
managed::{ManagedClient, ManagedEndReason, ManagedEvent, TargetInstance},
|
||||
managed::{
|
||||
ManagedClient, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy,
|
||||
ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetLifecycle,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
@@ -26,15 +26,18 @@ use tokio::{
|
||||
time::{sleep, Instant},
|
||||
};
|
||||
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(1500);
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
pub async fn run_tui(
|
||||
args: TuiArgs,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let setup = tokio::select! {
|
||||
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||
};
|
||||
let continuous = args.is_continuous();
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
@@ -46,8 +49,12 @@ pub async fn run_tui(
|
||||
);
|
||||
state.set_status(TuiStatus::Opening);
|
||||
terminal.draw(&state)?;
|
||||
let (task, handle) = match ManagedClient::task(setup.managed_config(), setup.managed_targets())
|
||||
{
|
||||
let desired_targets = setup.managed_targets();
|
||||
let mut config = setup.managed_config();
|
||||
if continuous {
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
}
|
||||
let (task, handle) = match ManagedClient::task(config, desired_targets.clone()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
state.set_error(error.to_string());
|
||||
@@ -75,7 +82,10 @@ pub async fn run_tui(
|
||||
tokio::pin!(render);
|
||||
let mut interrupted = false;
|
||||
let mut stop_requested = false;
|
||||
let mut terminal_targets = HashSet::new();
|
||||
let retry = sleep(RETRY_DELAY);
|
||||
tokio::pin!(retry);
|
||||
let mut retry_armed = false;
|
||||
let mut update_receipt = None;
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut events_closed = false;
|
||||
let mut status_closed = false;
|
||||
@@ -92,18 +102,12 @@ pub async fn run_tui(
|
||||
}
|
||||
changed = status.changed(), if !status_closed => {
|
||||
status_closed = changed.is_err();
|
||||
let snapshot = status.borrow_and_update().clone();
|
||||
if request_managed_stop_for_peer_close(
|
||||
continuous, interrupted, snapshot.peer_closed_target_outcomes,
|
||||
&mut stop_requested,
|
||||
) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
state.process_managed_status(&status.borrow_and_update());
|
||||
force_render = true;
|
||||
}
|
||||
event = events.recv(), if !events_closed => match event {
|
||||
Ok(event) => force_render = process_tui_event(
|
||||
event, &mut state, &mut terminal_targets,
|
||||
event, &mut state,
|
||||
),
|
||||
Err(RecvError::Lagged(count)) => {
|
||||
dropped_events = dropped_events.saturating_add(count);
|
||||
@@ -138,11 +142,55 @@ pub async fn run_tui(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = &mut retry, if retry_armed && !interrupted && !*shutdown.borrow() => {
|
||||
state.process_managed_status(&status.borrow());
|
||||
retry_armed = false;
|
||||
update_receipt = match handle.update_targets(desired_targets.clone()) {
|
||||
Ok(receipt) => Some(receipt),
|
||||
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed)
|
||||
if stop_requested || *shutdown.borrow() => None,
|
||||
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||
};
|
||||
}
|
||||
result = async { update_receipt.as_mut().unwrap().await },
|
||||
if update_receipt.is_some() => {
|
||||
update_receipt = None;
|
||||
match result {
|
||||
Ok(ack) => {
|
||||
state.process_managed_status(&ack.status);
|
||||
force_render = true;
|
||||
}
|
||||
Err(ManagedCommandApplyError::Stopping
|
||||
| ManagedCommandApplyError::AcknowledgementDisconnected)
|
||||
if stop_requested || *shutdown.borrow() => {}
|
||||
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||
}
|
||||
}
|
||||
_ = &mut render, if !state.paused => force_render = true,
|
||||
}
|
||||
if interrupted && request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
if continuous
|
||||
&& !interrupted
|
||||
&& !stop_requested
|
||||
&& !retry_armed
|
||||
&& update_receipt.is_none()
|
||||
&& status.borrow().targets.iter().any(|target| {
|
||||
target.desired
|
||||
&& target.outcome.as_ref().is_some_and(|outcome| {
|
||||
matches!(
|
||||
outcome.end_reason,
|
||||
ManagedTargetEndReason::Failed(_)
|
||||
| ManagedTargetEndReason::PeerClosed
|
||||
| ManagedTargetEndReason::TestComplete
|
||||
)
|
||||
})
|
||||
})
|
||||
{
|
||||
retry.as_mut().reset(Instant::now() + RETRY_DELAY);
|
||||
retry_armed = true;
|
||||
}
|
||||
if force_render {
|
||||
terminal.draw(&state)?;
|
||||
// Schedule from the completed draw, never catch up missed frames.
|
||||
@@ -160,37 +208,32 @@ pub async fn run_tui(
|
||||
}
|
||||
};
|
||||
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||
process_tui_event(event, &mut state, &mut terminal_targets);
|
||||
process_tui_event(event, &mut state);
|
||||
Ok::<(), std::convert::Infallible>(())
|
||||
})?;
|
||||
state.mark_dropped_managed_events(dropped_events);
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
if !continuous && 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);
|
||||
}
|
||||
state.process_target_outcome(target);
|
||||
}
|
||||
interrupted |= *shutdown.borrow();
|
||||
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
|
||||
)
|
||||
})
|
||||
_ => (!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 {
|
||||
@@ -203,11 +246,7 @@ pub async fn run_tui(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_tui_event(
|
||||
event: ManagedEvent,
|
||||
state: &mut TuiState,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
) -> bool {
|
||||
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let force_render = matches!(
|
||||
@@ -221,10 +260,19 @@ fn process_tui_event(
|
||||
force_render
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
state.process_target_outcome(&outcome);
|
||||
true
|
||||
}
|
||||
ManagedEvent::TargetStateChanged {
|
||||
target,
|
||||
lifecycle:
|
||||
ManagedTargetLifecycle::Pending
|
||||
| ManagedTargetLifecycle::Connecting
|
||||
| ManagedTargetLifecycle::Opening,
|
||||
} => {
|
||||
state.process_target_opening(&target);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ use crossterm::{
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance};
|
||||
use irtt_client::managed::{
|
||||
ManagedStatus, ManagedTargetEndReason, ManagedTargetLifecycle, ManagedTargetOutcome,
|
||||
TargetInstance,
|
||||
};
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, NegotiationResult, OneWayDelaySample, RttSample, SignedDuration,
|
||||
};
|
||||
@@ -22,7 +25,10 @@ use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
symbols,
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Row, Table, Wrap},
|
||||
widgets::{
|
||||
canvas::{Canvas, Line as CanvasLine, Painter, Shape},
|
||||
Axis, Block, Chart, Paragraph, Row, Table, Wrap,
|
||||
},
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
@@ -37,7 +43,7 @@ use crate::{
|
||||
shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup},
|
||||
};
|
||||
|
||||
const HISTORY_LIMIT: usize = 100_000;
|
||||
const HISTORY_LIMIT: usize = 500_000;
|
||||
const RECENT_EVENT_LIMIT: usize = 80;
|
||||
const MIN_WIDTH: u16 = 56;
|
||||
const MIN_HEIGHT: u16 = 18;
|
||||
@@ -183,13 +189,91 @@ impl TuiState {
|
||||
|
||||
pub(super) fn process_target_event(&mut self, target: &TargetInstance, event: &ClientEvent) {
|
||||
let idx = self.ensure_target(target.id.as_str());
|
||||
if !self.accept_generation(idx, target.generation) {
|
||||
return;
|
||||
}
|
||||
if self.targets[idx].terminal_generation == Some(target.generation) {
|
||||
self.targets[idx].process_session_metadata(event);
|
||||
if matches!(
|
||||
event,
|
||||
ClientEvent::SessionStarted(_)
|
||||
| ClientEvent::NoTestCompleted(_)
|
||||
| ClientEvent::SessionClosed { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.process_event_for_target(idx, event);
|
||||
}
|
||||
|
||||
fn accept_generation(&mut self, idx: usize, generation: u64) -> bool {
|
||||
let target = &mut self.targets[idx];
|
||||
if target
|
||||
.generation
|
||||
.is_some_and(|current| current > generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if target.generation != Some(generation) {
|
||||
target.generation = Some(generation);
|
||||
target.stats = StatsCollector::new(stats_config(self.config.duration.is_none()));
|
||||
target.session = None;
|
||||
target.negotiated = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn process_target_opening(&mut self, instance: &TargetInstance) {
|
||||
let idx = self.ensure_target(instance.id.as_str());
|
||||
let new_generation = self.targets[idx].generation != Some(instance.generation);
|
||||
if self.accept_generation(idx, instance.generation) && new_generation {
|
||||
self.targets[idx].status = TargetStatus::Opening;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_managed_status(&mut self, status: &ManagedStatus) {
|
||||
// Consume durable endings before accepting replacement generations.
|
||||
// Receipts retain this history even if a watch update was coalesced.
|
||||
for outcome in status.recent_target_outcomes.iter().chain(
|
||||
status
|
||||
.targets
|
||||
.iter()
|
||||
.filter_map(|target| target.outcome.as_deref()),
|
||||
) {
|
||||
self.process_target_outcome(outcome);
|
||||
}
|
||||
for target in status.targets.iter() {
|
||||
if matches!(
|
||||
target.lifecycle,
|
||||
ManagedTargetLifecycle::Pending
|
||||
| ManagedTargetLifecycle::Connecting
|
||||
| ManagedTargetLifecycle::Opening
|
||||
) {
|
||||
self.process_target_opening(&target.target);
|
||||
} else {
|
||||
let idx = self.ensure_target(target.target.id.as_str());
|
||||
if self.accept_generation(idx, target.target.generation)
|
||||
&& self.targets[idx].terminal_generation != Some(target.target.generation)
|
||||
&& target.lifecycle == ManagedTargetLifecycle::Active
|
||||
{
|
||||
self.targets[idx].status = TargetStatus::Active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_target_outcome(&mut self, outcome: &ManagedTargetOutcome) {
|
||||
let label = outcome.target.id.as_str();
|
||||
let idx = self.ensure_target(label);
|
||||
|
||||
if self.targets[idx]
|
||||
.terminal_generation
|
||||
.is_some_and(|generation| generation >= outcome.target.generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let current = self.accept_generation(idx, outcome.target.generation);
|
||||
self.targets[idx].terminal_generation = Some(outcome.target.generation);
|
||||
let (status, recent, primary_warning) = match &outcome.end_reason {
|
||||
ManagedTargetEndReason::TestComplete => {
|
||||
(TargetStatus::Closed, "test completed".to_owned(), None)
|
||||
@@ -237,8 +321,10 @@ impl TuiState {
|
||||
};
|
||||
|
||||
let target = &mut self.targets[idx];
|
||||
target.remote = outcome.remote.map(|remote| remote.to_string());
|
||||
target.status = status;
|
||||
if current {
|
||||
target.remote = outcome.remote.map(|remote| remote.to_string());
|
||||
target.status = status;
|
||||
}
|
||||
if let Some(warning) = &warning {
|
||||
target.last_warning = Some(warning.clone());
|
||||
}
|
||||
@@ -250,10 +336,11 @@ impl TuiState {
|
||||
self.push_event(format!("{label}: {cleanup_warning}"));
|
||||
}
|
||||
|
||||
if self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.status.is_terminal())
|
||||
if self.config.duration.is_some()
|
||||
&& self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.status.is_terminal())
|
||||
{
|
||||
self.status = if self.targets.iter().any(|target| target.status.is_success()) {
|
||||
TuiStatus::Complete
|
||||
@@ -318,7 +405,8 @@ impl TuiState {
|
||||
server_timing,
|
||||
..
|
||||
} => {
|
||||
target.push_graph_sample(GraphSample {
|
||||
target.push_graph_sample(LatestSample {
|
||||
generation: target.generation.expect("client event has a generation"),
|
||||
timestamp: *received_at,
|
||||
seq: *seq,
|
||||
rtt: *rtt,
|
||||
@@ -364,10 +452,11 @@ impl TuiState {
|
||||
};
|
||||
if let Some(status) = global_status {
|
||||
self.status = if status == TuiStatus::Complete
|
||||
&& !self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.status.is_terminal())
|
||||
&& (self.config.duration.is_none()
|
||||
|| !self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.status.is_terminal()))
|
||||
{
|
||||
TuiStatus::Running
|
||||
} else {
|
||||
@@ -519,12 +608,14 @@ impl TuiState {
|
||||
#[derive(Debug)]
|
||||
struct TuiTargetState {
|
||||
label: String,
|
||||
generation: Option<u64>,
|
||||
terminal_generation: Option<u64>,
|
||||
remote: Option<String>,
|
||||
session: Option<String>,
|
||||
status: TargetStatus,
|
||||
negotiated: Option<NegotiationResult>,
|
||||
graph_history: VecDeque<GraphSample>,
|
||||
last_sample: Option<GraphSample>,
|
||||
last_sample: Option<LatestSample>,
|
||||
last_warning: Option<String>,
|
||||
stats: StatsCollector,
|
||||
}
|
||||
@@ -533,6 +624,8 @@ impl TuiTargetState {
|
||||
fn new(label: String, stats_config: irtt_stats::StatsConfig) -> Self {
|
||||
Self {
|
||||
label,
|
||||
generation: None,
|
||||
terminal_generation: None,
|
||||
remote: None,
|
||||
session: None,
|
||||
status: TargetStatus::Opening,
|
||||
@@ -544,9 +637,39 @@ impl TuiTargetState {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_graph_sample(&mut self, sample: GraphSample) {
|
||||
fn process_session_metadata(&mut self, event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::SessionStarted(started) => {
|
||||
self.remote = Some(started.remote.to_string());
|
||||
self.session = Some(format!("{:#x}", started.token));
|
||||
self.negotiated = Some(started.negotiation.clone());
|
||||
}
|
||||
ClientEvent::NoTestCompleted(completed) => {
|
||||
self.remote = Some(completed.remote.to_string());
|
||||
self.negotiated = Some(completed.negotiation.clone());
|
||||
}
|
||||
ClientEvent::SessionClosed { remote, token, .. } => {
|
||||
self.remote = Some(remote.to_string());
|
||||
self.session = Some(format!("{token:#x}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_graph_sample(&mut self, sample: LatestSample) {
|
||||
let history = GraphSample {
|
||||
timestamp: sample.timestamp,
|
||||
values_ms: GraphMetric::ALL.map(|metric| {
|
||||
metric
|
||||
.value_ns(&sample)
|
||||
.map_or(f64::NAN, |ns| ns as f64 / 1_000_000.0)
|
||||
}),
|
||||
break_before: self
|
||||
.last_sample
|
||||
.is_none_or(|last| last.generation != sample.generation),
|
||||
};
|
||||
self.last_sample = Some(sample);
|
||||
push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT);
|
||||
push_bounded(&mut self.graph_history, history, HISTORY_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +937,8 @@ enum GraphMetric {
|
||||
}
|
||||
|
||||
impl GraphMetric {
|
||||
const ALL: &[Self] = &[
|
||||
// Enum order also indexes the retained graph values.
|
||||
const ALL: [Self; 6] = [
|
||||
Self::EffectiveRtt,
|
||||
Self::RawRtt,
|
||||
Self::AdjustedRtt,
|
||||
@@ -854,7 +978,7 @@ impl GraphMetric {
|
||||
}
|
||||
}
|
||||
|
||||
fn value_ns(self, sample: &GraphSample) -> Option<i128> {
|
||||
fn value_ns(self, sample: &LatestSample) -> Option<i128> {
|
||||
match self {
|
||||
Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()),
|
||||
Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()),
|
||||
@@ -884,8 +1008,26 @@ impl GraphMetric {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
// Retained values use the same nanoseconds-to-f64 milliseconds conversion as
|
||||
// the chart. NaN means absent and never reaches the canvas as a finite point.
|
||||
// Keep both clocks: geometry is monotonic, while labels use local wall time.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct GraphSample {
|
||||
timestamp: ClientTimestamp,
|
||||
values_ms: [f64; 6],
|
||||
break_before: bool,
|
||||
}
|
||||
|
||||
impl GraphSample {
|
||||
fn value_ms(&self, metric: GraphMetric) -> Option<f64> {
|
||||
let value = self.values_ms[metric as usize];
|
||||
value.is_finite().then_some(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct LatestSample {
|
||||
generation: u64,
|
||||
timestamp: ClientTimestamp,
|
||||
seq: u32,
|
||||
rtt: RttSample,
|
||||
@@ -1235,23 +1377,58 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
||||
return;
|
||||
}
|
||||
let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind());
|
||||
let x_labels = viewport_x_axis_labels(state, viewport, area.width);
|
||||
let y_labels = y_axis_labels(min_y, max_y, y_axis_label_count(area.height));
|
||||
let inner = block.inner(area);
|
||||
// This chart has labels on both axes, no axis titles and no legend.
|
||||
// Match the pinned Chart's label gutter and its two bottom axis rows.
|
||||
let gutter = y_labels
|
||||
.iter()
|
||||
.map(Span::width)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(
|
||||
x_labels
|
||||
.first()
|
||||
.map_or(0, |label| label.width().saturating_sub(1)),
|
||||
)
|
||||
.min(usize::from(inner.width / 3)) as u16
|
||||
+ 1;
|
||||
let plot = Rect::new(
|
||||
inner.x + gutter,
|
||||
inner.y,
|
||||
inner.width.saturating_sub(gutter),
|
||||
inner.height.saturating_sub(2),
|
||||
);
|
||||
frame.render_widget(
|
||||
Chart::new(chart_datasets(&series))
|
||||
Chart::new(vec![])
|
||||
.block(block)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.bounds(viewport_x_bounds(viewport))
|
||||
.labels(viewport_x_axis_labels(state, viewport, area.width))
|
||||
.labels(x_labels)
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default()
|
||||
.bounds([min_y, max_y])
|
||||
.labels(y_axis_labels(min_y, max_y, y_axis_label_count(area.height)))
|
||||
.labels(y_labels)
|
||||
.style(Style::default().fg(Color::Gray)),
|
||||
),
|
||||
area,
|
||||
);
|
||||
frame.render_widget(
|
||||
Canvas::default()
|
||||
.marker(symbols::Marker::Braille)
|
||||
.x_bounds(viewport_x_bounds(viewport))
|
||||
.y_bounds([min_y, max_y])
|
||||
.paint(|ctx| {
|
||||
for target in &series {
|
||||
ctx.draw(target);
|
||||
}
|
||||
}),
|
||||
plot,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -1278,22 +1455,30 @@ fn target_metric_series(
|
||||
let first = target
|
||||
.graph_history
|
||||
.partition_point(|sample| sample.timestamp.mono < viewport.start);
|
||||
let mut previous = target
|
||||
.graph_history
|
||||
.range(..first)
|
||||
.rev()
|
||||
.find_map(|sample| {
|
||||
metric
|
||||
.value_ns(sample)
|
||||
.map(|ns| (sample.timestamp.mono, ns as f64 / 1_000_000.0))
|
||||
});
|
||||
let points = target.graph_history.range(first..).filter_map(|sample| {
|
||||
metric
|
||||
.value_ns(sample)
|
||||
.map(|ns| (sample.timestamp.mono, ns as f64 / 1_000_000.0))
|
||||
});
|
||||
let mut previous = None;
|
||||
for sample in target.graph_history.range(..first).rev() {
|
||||
if let Some(value) = sample.value_ms(metric) {
|
||||
previous = Some((sample.timestamp.mono, value));
|
||||
break;
|
||||
}
|
||||
if sample.break_before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
for point in points {
|
||||
for sample in target.graph_history.range(first..) {
|
||||
// Consume boundaries even when this metric is absent in the first
|
||||
// replies of a new session; filtering them out would bridge an outage.
|
||||
if sample.break_before {
|
||||
if !data.is_empty() && data.last().is_some_and(|(x, _): &(f64, f64)| x.is_finite()) {
|
||||
data.push((f64::NAN, f64::NAN));
|
||||
}
|
||||
previous = None;
|
||||
}
|
||||
let Some(value) = sample.value_ms(metric) else {
|
||||
continue;
|
||||
};
|
||||
let point = (sample.timestamp.mono, value);
|
||||
if let Some(before) = previous {
|
||||
// Only genuine crossing segments contribute boundary points. In
|
||||
// particular, never extend the latest sample to the live edge.
|
||||
@@ -1318,6 +1503,10 @@ fn target_metric_series(
|
||||
}
|
||||
previous = Some(point);
|
||||
}
|
||||
// A boundary beyond the viewport needs no trailing break.
|
||||
if data.last().is_some_and(|(x, _)| !x.is_finite()) {
|
||||
data.pop();
|
||||
}
|
||||
(!data.is_empty()).then_some(ChartSeries {
|
||||
style: target_style(target_idx),
|
||||
data,
|
||||
@@ -1333,17 +1522,32 @@ fn interpolate_graph_value(a: (Instant, f64), b: (Instant, f64), at: Instant) ->
|
||||
a.1 + (b.1 - a.1) * (part / whole)
|
||||
}
|
||||
|
||||
fn chart_datasets(series: &[ChartSeries]) -> Vec<Dataset<'_>> {
|
||||
series
|
||||
.iter()
|
||||
.map(|series| {
|
||||
Dataset::default()
|
||||
.marker(symbols::Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(series.style)
|
||||
.data(&series.data)
|
||||
})
|
||||
.collect()
|
||||
// Ratatui 0.30.2 casts NaN coordinates to grid positions. Filter breaks
|
||||
// ourselves so one canvas shape and one backing buffer suffice per target.
|
||||
impl Shape for ChartSeries {
|
||||
fn draw(&self, painter: &mut Painter) {
|
||||
let color = self.style.fg.unwrap_or(Color::Reset);
|
||||
for &(x, y) in &self.data {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
if let Some((x, y)) = painter.get_point(x, y) {
|
||||
painter.paint(x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
for pair in self.data.windows(2) {
|
||||
let [(x1, y1), (x2, y2)] = [pair[0], pair[1]];
|
||||
if [x1, y1, x2, y2].iter().all(|value| value.is_finite()) {
|
||||
CanvasLine {
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
color,
|
||||
}
|
||||
.draw(painter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target_style(idx: usize) -> Style {
|
||||
@@ -1365,11 +1569,11 @@ fn chart_y_bounds(series: &[ChartSeries], axis_kind: ChartAxisKind) -> (f64, f64
|
||||
series
|
||||
.data
|
||||
.iter()
|
||||
.filter(|(_, value)| value.is_finite())
|
||||
.map(|(_, value)| match axis_kind {
|
||||
ChartAxisKind::NonNegative => (*value).max(0.0),
|
||||
ChartAxisKind::Signed => *value,
|
||||
})
|
||||
.filter(|value| value.is_finite())
|
||||
});
|
||||
let Some(first) = values.next() else {
|
||||
return default_y_bounds(axis_kind);
|
||||
@@ -1692,3 +1896,293 @@ fn format_optional_hex(value: Option<u64>) -> String {
|
||||
.map(|value| format!("0x{value:x}"))
|
||||
.unwrap_or_else(|| ABSENT.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn durable_failure_and_replacement_reset_stats_without_client_events() {
|
||||
use irtt_client::managed::{
|
||||
ManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedTargetConfig,
|
||||
};
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let desired = vec![ManagedTargetConfig::new("monitor", "127.0.0.1:99999")];
|
||||
let (task, handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..Default::default()
|
||||
},
|
||||
desired.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut status = handle.subscribe_status();
|
||||
let instance = status.borrow().targets[0].target.clone();
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig {
|
||||
interval: Duration::from_secs(1),
|
||||
duration: None,
|
||||
timeout: Duration::from_secs(4),
|
||||
target_probes: None,
|
||||
pacing: GroupPacingArg::Staggered,
|
||||
},
|
||||
["monitor".to_owned()],
|
||||
);
|
||||
state.process_target_event(
|
||||
&instance,
|
||||
&ClientEvent::EchoSent {
|
||||
seq: 42,
|
||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||
scheduled_at: None,
|
||||
sent_at: ClientTimestamp::now(),
|
||||
bytes: 60,
|
||||
send_call: Duration::ZERO,
|
||||
timer_error: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(state.targets[0].stats.snapshot().packets.packets_sent, 1);
|
||||
let run = tokio::spawn(task);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
status.changed().await.unwrap();
|
||||
if status.borrow().targets[0].outcome.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Never consume broadcast events: status owns the failure evidence.
|
||||
let snapshot = status.borrow_and_update().clone();
|
||||
let outcome = snapshot.targets[0].outcome.as_ref().unwrap();
|
||||
state.process_managed_status(&snapshot);
|
||||
assert_eq!(state.targets[0].status, TargetStatus::Failed);
|
||||
assert!(state.targets[0].last_warning.is_some());
|
||||
let negotiation = NegotiationResult {
|
||||
accepted: irtt_client::AcceptedSessionParameters {
|
||||
duration: None,
|
||||
interval: Duration::from_secs(1),
|
||||
length: 60,
|
||||
received_stats: irtt_proto::ReceivedStats::Both,
|
||||
stamp_at: irtt_proto::StampAt::Both,
|
||||
clock: irtt_proto::Clock::Both,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
},
|
||||
peer_params: irtt_proto::Params::default(),
|
||||
changes: vec![],
|
||||
};
|
||||
let remote = "127.0.0.1:2112".parse().unwrap();
|
||||
let global_status = state.status;
|
||||
for event in [
|
||||
ClientEvent::SessionStarted(irtt_client::SessionStarted {
|
||||
remote,
|
||||
token: 123,
|
||||
negotiation: negotiation.clone(),
|
||||
at: ClientTimestamp::now(),
|
||||
}),
|
||||
ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted {
|
||||
remote,
|
||||
negotiation: negotiation.clone(),
|
||||
at: ClientTimestamp::now(),
|
||||
}),
|
||||
ClientEvent::SessionClosed {
|
||||
remote,
|
||||
token: 123,
|
||||
at: ClientTimestamp::now(),
|
||||
},
|
||||
] {
|
||||
state.process_target_event(&instance, &event);
|
||||
assert_eq!(state.targets[0].remote.as_deref(), Some("127.0.0.1:2112"));
|
||||
assert_eq!(state.targets[0].session.as_deref(), Some("0x7b"));
|
||||
assert_eq!(state.targets[0].negotiated.as_ref(), Some(&negotiation));
|
||||
assert_eq!(state.targets[0].status, TargetStatus::Failed);
|
||||
assert_eq!(state.status, global_status);
|
||||
assert_eq!(state.targets[0].stats.snapshot().packets.packets_sent, 1);
|
||||
}
|
||||
let event_count = state.recent_events.len();
|
||||
state.process_target_outcome(outcome);
|
||||
assert_eq!(
|
||||
state.recent_events.len(),
|
||||
event_count,
|
||||
"no duplicate ending"
|
||||
);
|
||||
|
||||
let ack = handle.update_targets(desired).unwrap().await.unwrap();
|
||||
// Opening events may arrive before their update receipt.
|
||||
let mut late_state =
|
||||
TuiState::with_target_labels(state.config.clone(), ["monitor".to_owned()]);
|
||||
late_state.process_target_opening(&ack.status.targets[0].target);
|
||||
late_state.process_managed_status(&ack.status);
|
||||
assert_eq!(late_state.targets[0].status, TargetStatus::Opening);
|
||||
assert!(late_state.targets[0].last_warning.is_some());
|
||||
|
||||
state.process_managed_status(&ack.status);
|
||||
assert_eq!(state.targets[0].status, TargetStatus::Opening);
|
||||
assert_eq!(state.targets[0].stats.snapshot().packets.packets_sent, 0);
|
||||
assert!(
|
||||
state.targets[0].last_warning.is_some(),
|
||||
"failure evidence survives reprovisioning"
|
||||
);
|
||||
loop {
|
||||
status.changed().await.unwrap();
|
||||
if status.borrow().targets[0].outcome.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.process_managed_status(&status.borrow_and_update());
|
||||
assert_eq!(state.targets[0].status, TargetStatus::Failed);
|
||||
assert_eq!(state.targets[0].stats.snapshot().packets.packets_sent, 0);
|
||||
// An older receipt must not revive a durably failed generation.
|
||||
state.process_managed_status(&ack.status);
|
||||
assert_eq!(state.targets[0].status, TargetStatus::Failed);
|
||||
handle.stop().await;
|
||||
run.await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_history_clips_within_sessions_and_leaves_the_outage_empty() {
|
||||
let start = Instant::now();
|
||||
let mut target = TuiTargetState::new("monitor".into(), stats_config(true));
|
||||
for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] {
|
||||
let raw = Duration::from_millis(seconds + 1);
|
||||
target.push_graph_sample(LatestSample {
|
||||
generation,
|
||||
timestamp: ClientTimestamp {
|
||||
mono: start + Duration::from_secs(seconds),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(seconds),
|
||||
},
|
||||
seq: 0,
|
||||
rtt: RttSample {
|
||||
raw,
|
||||
adjusted: (seconds != 6)
|
||||
.then_some(SignedDuration::from_nanos(-(raw.as_nanos() as i128))),
|
||||
effective: SignedDuration::from_nanos(raw.as_nanos() as i128),
|
||||
},
|
||||
one_way: None,
|
||||
server_processing: None,
|
||||
});
|
||||
}
|
||||
let series = |left, right| {
|
||||
target_metric_series(
|
||||
&target,
|
||||
0,
|
||||
GraphViewportRange {
|
||||
start: start + Duration::from_secs(left),
|
||||
end: start + Duration::from_secs(right),
|
||||
window: Duration::from_secs(right - left),
|
||||
},
|
||||
GraphMetric::RawRtt,
|
||||
)
|
||||
};
|
||||
let clipped = series(1, 7).unwrap();
|
||||
assert_eq!(clipped.data.len(), 5);
|
||||
assert_eq!(&clipped.data[..2], [(0.0, 2.0), (1.0, 3.0)]);
|
||||
assert!(clipped.data[2].0.is_nan() && clipped.data[2].1.is_nan());
|
||||
assert_eq!(&clipped.data[3..], [(5.0, 7.0), (6.0, 8.0)]);
|
||||
// Check the pinned renderer: one guarded shape must render exactly
|
||||
// like independent finite datasets, including their isolated points.
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
widgets::{Dataset, GraphType, Widget},
|
||||
};
|
||||
let area = Rect::new(0, 0, 50, 20);
|
||||
let mut actual = Buffer::empty(area);
|
||||
Canvas::default()
|
||||
.marker(symbols::Marker::Braille)
|
||||
.x_bounds([0.0, 6.0])
|
||||
.y_bounds([0.0, 10.0])
|
||||
.paint(|ctx| ctx.draw(&clipped))
|
||||
.render(area, &mut actual);
|
||||
let datasets = [&clipped.data[..2], &clipped.data[3..]].map(|data| {
|
||||
Dataset::default()
|
||||
.marker(symbols::Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(clipped.style)
|
||||
.data(data)
|
||||
});
|
||||
let mut expected = Buffer::empty(area);
|
||||
Chart::new(datasets.to_vec())
|
||||
.x_axis(Axis::default().bounds([0.0, 6.0]))
|
||||
.y_axis(Axis::default().bounds([0.0, 10.0]))
|
||||
.render(area, &mut expected);
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"no bogus pixels or lines through NaN breaks"
|
||||
);
|
||||
let finite = ChartSeries {
|
||||
style: clipped.style,
|
||||
data: clipped
|
||||
.data
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(x, _)| x.is_finite())
|
||||
.collect(),
|
||||
};
|
||||
assert_eq!(
|
||||
chart_y_bounds(std::slice::from_ref(&clipped), ChartAxisKind::NonNegative),
|
||||
chart_y_bounds(&[finite], ChartAxisKind::NonNegative)
|
||||
);
|
||||
assert!(series(3, 5).is_none(), "no interpolation across sessions");
|
||||
assert!(series(9, 10).is_none(), "no extrapolation to the live edge");
|
||||
let adjusted = |left, right| {
|
||||
target_metric_series(
|
||||
&target,
|
||||
0,
|
||||
GraphViewportRange {
|
||||
start: start + Duration::from_secs(left),
|
||||
end: start + Duration::from_secs(right),
|
||||
window: Duration::from_secs(right - left),
|
||||
},
|
||||
GraphMetric::AdjustedRtt,
|
||||
)
|
||||
};
|
||||
let missing = adjusted(1, 9).unwrap();
|
||||
assert_eq!(&missing.data[..2], [(0.0, -2.0), (1.0, -3.0)]);
|
||||
assert!(missing.data[2].0.is_nan());
|
||||
assert_eq!(&missing.data[3..], [(7.0, -9.0)]);
|
||||
assert!(
|
||||
adjusted(3, 7).is_none(),
|
||||
"absent metric cannot bridge sessions"
|
||||
);
|
||||
assert_eq!(
|
||||
adjusted(7, 9).unwrap().data,
|
||||
[(1.0, -9.0)],
|
||||
"predecessor search stops at an absent boundary metric"
|
||||
);
|
||||
let template = target.last_sample.unwrap();
|
||||
target.graph_history.clear();
|
||||
for generation in 0..10_000 {
|
||||
target.push_graph_sample(LatestSample {
|
||||
generation,
|
||||
timestamp: ClientTimestamp {
|
||||
mono: start + Duration::from_secs(generation),
|
||||
..template.timestamp
|
||||
},
|
||||
..template
|
||||
});
|
||||
}
|
||||
let visible = target_metric_series(
|
||||
&target,
|
||||
0,
|
||||
GraphViewportRange {
|
||||
start: start + Duration::from_secs(9_994),
|
||||
end: start + Duration::from_secs(9_999),
|
||||
window: Duration::from_secs(5),
|
||||
},
|
||||
GraphMetric::RawRtt,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
visible.data.len(),
|
||||
11,
|
||||
"six visible generations in one buffer"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,18 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
#[derive(Clone, clap::Args)]
|
||||
pub struct CommonClientArgs {
|
||||
/// Use IPv4 only.
|
||||
#[arg(short = '4', long, conflicts_with_all = ["ipv6", "dual_stack"])]
|
||||
pub ipv4: bool,
|
||||
|
||||
/// Use IPv6 only.
|
||||
#[arg(short = '6', long, conflicts_with_all = ["ipv4", "dual_stack"])]
|
||||
pub ipv6: bool,
|
||||
|
||||
/// Probe available IPv4 and IPv6 paths separately for each hostname.
|
||||
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
|
||||
pub dual_stack: bool,
|
||||
|
||||
/// Probe interval.
|
||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||
pub interval: Duration,
|
||||
@@ -66,6 +78,9 @@ pub struct CommonClientArgs {
|
||||
impl fmt::Debug for CommonClientArgs {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommonClientArgs")
|
||||
.field("ipv4", &self.ipv4)
|
||||
.field("ipv6", &self.ipv6)
|
||||
.field("dual_stack", &self.dual_stack)
|
||||
.field("interval", &self.interval)
|
||||
.field("length", &self.length)
|
||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
||||
SocketConfig,
|
||||
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
|
||||
SessionRequest, SocketConfig,
|
||||
};
|
||||
|
||||
use super::args::CommonClientArgs;
|
||||
|
||||
impl CommonClientArgs {
|
||||
pub fn address_family(&self) -> AddressFamily {
|
||||
if self.ipv4 {
|
||||
AddressFamily::Ipv4
|
||||
} else if self.ipv6 {
|
||||
AddressFamily::Ipv6
|
||||
} else {
|
||||
AddressFamily::Any
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared client configuration template for a managed run.
|
||||
///
|
||||
/// The config is endpoint-independent; managed targets own their addresses.
|
||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||
ClientConfig {
|
||||
address_family: self.address_family(),
|
||||
open: OpenPolicy {
|
||||
negotiation: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
|
||||
@@ -15,6 +15,6 @@ pub use prepare::{
|
||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
pub use targets::{
|
||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||
PreparedTarget, TargetArg, TargetSpec,
|
||||
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||
TargetPreparation, TargetSpec,
|
||||
};
|
||||
@@ -16,7 +16,7 @@ use irtt_client::{
|
||||
|
||||
use super::{
|
||||
args::CommonClientArgs,
|
||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
||||
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
|
||||
PreparedTarget,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
|
||||
pub pacing: ManagedPacing,
|
||||
/// Whether this stream may replace its desired set from stdin.
|
||||
pub stdin_controlled: bool,
|
||||
/// Preparation policy and family selections for later stdin declarations.
|
||||
pub preparation: TargetPreparation,
|
||||
}
|
||||
|
||||
impl ManagedRunSetup {
|
||||
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
|
||||
///
|
||||
/// Targets are validated before any configuration is built, so a bad target set
|
||||
/// fails before the caller can act on a half-prepared run.
|
||||
pub fn prepare_managed_run(
|
||||
pub async fn prepare_managed_run(
|
||||
common: &CommonClientArgs,
|
||||
duration: Duration,
|
||||
selection: TargetSelection<'_>,
|
||||
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
|
||||
} else {
|
||||
target_specs(selection.targets)?
|
||||
};
|
||||
let targets = prepare_managed_targets(specs)?;
|
||||
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
|
||||
.prepare(
|
||||
specs,
|
||||
selection
|
||||
.stdin_controlled
|
||||
.then_some(STDIN_MAX_DESIRED_TARGETS),
|
||||
)
|
||||
.await?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
preparation,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
use std::{collections::HashSet, fmt};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||
Authentication, HmacKey,
|
||||
AddressFamily, Authentication, HmacKey,
|
||||
};
|
||||
|
||||
/// One raw positional target captured by Clap.
|
||||
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field("address_family", &self.managed.address_family)
|
||||
.field("auth", &self.managed.auth)
|
||||
.finish()
|
||||
}
|
||||
@@ -257,17 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs_with_empty(targets, false)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = spec.auth;
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
});
|
||||
/// Family selections for the current desired declarations, never resolved IPs.
|
||||
/// Unchanged stdin declarations retain their selections; removed declarations
|
||||
/// are forgotten, so adding them again performs fresh discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TargetPreparation {
|
||||
family: AddressFamily,
|
||||
dual_stack: bool,
|
||||
discovered: HashMap<String, (String, [bool; 2])>,
|
||||
}
|
||||
|
||||
impl TargetPreparation {
|
||||
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
|
||||
Self {
|
||||
family,
|
||||
dual_stack,
|
||||
discovered: HashMap::new(),
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
|
||||
pub async fn prepare(
|
||||
mut self,
|
||||
specs: Vec<TargetSpec>,
|
||||
maximum_targets: Option<usize>,
|
||||
) -> Result<(Vec<PreparedTarget>, Self), String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
let mut discovered = HashMap::new();
|
||||
let mut labels = HashSet::new();
|
||||
for (index, spec) in specs.into_iter().enumerate() {
|
||||
let families = if let Some(ip) = literal_ip(&spec.addr) {
|
||||
match (self.family, ip) {
|
||||
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
|
||||
return Err(format!(
|
||||
"target {}: IPv6 literal conflicts with --ipv4",
|
||||
index + 1
|
||||
))
|
||||
}
|
||||
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
|
||||
return Err(format!(
|
||||
"target {}: IPv4 literal conflicts with --ipv6",
|
||||
index + 1
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else if self.dual_stack {
|
||||
let families = match self.discovered.get(&spec.label) {
|
||||
Some((addr, families)) if *addr == spec.addr => *families,
|
||||
_ => {
|
||||
let endpoint = discovery_endpoint(&spec.addr)
|
||||
.map_err(|error| format!("target {}: {error}", index + 1))?;
|
||||
discovery_families(tokio::net::lookup_host(endpoint).await)
|
||||
.map_err(|error| format!("target {}: {error}", index + 1))?
|
||||
}
|
||||
};
|
||||
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
|
||||
Some(families)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for target in expand_target(spec, families) {
|
||||
if !labels.insert(target.label.clone()) {
|
||||
return Err("duplicate target label after address-family expansion".to_owned());
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
|
||||
return Err(format!(
|
||||
"target set exceeds the {maximum}-target limit after address-family expansion"
|
||||
));
|
||||
}
|
||||
}
|
||||
self.discovered = discovered;
|
||||
Ok((targets, self))
|
||||
}
|
||||
}
|
||||
|
||||
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
|
||||
endpoint
|
||||
.parse::<SocketAddr>()
|
||||
.map(|addr| addr.ip())
|
||||
.ok()
|
||||
.or_else(|| endpoint.parse().ok())
|
||||
.or_else(|| {
|
||||
if endpoint.starts_with('[') && endpoint.ends_with(']') {
|
||||
format!("{endpoint}:2112")
|
||||
.parse::<SocketAddr>()
|
||||
.ok()
|
||||
.map(|addr| addr.ip())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
|
||||
match endpoint.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
|
||||
if host.is_empty() || host.contains(':') || host.starts_with('[') {
|
||||
return Err("invalid server endpoint".to_owned());
|
||||
}
|
||||
Ok((host, port))
|
||||
}
|
||||
None => Ok((endpoint, 2112)),
|
||||
}
|
||||
}
|
||||
|
||||
fn discovery_families(
|
||||
result: io::Result<impl Iterator<Item = SocketAddr>>,
|
||||
) -> Result<[bool; 2], String> {
|
||||
let mut families = [false; 2];
|
||||
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
|
||||
families[usize::from(addr.is_ipv6())] = true;
|
||||
}
|
||||
if families == [false; 2] {
|
||||
return Err("DNS lookup returned no usable addresses".to_owned());
|
||||
}
|
||||
Ok(families)
|
||||
}
|
||||
|
||||
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
|
||||
let selections = match families {
|
||||
None => vec![(spec.label, None)],
|
||||
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
|
||||
.into_iter()
|
||||
.zip(families)
|
||||
.filter(|(_, exists)| *exists)
|
||||
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
|
||||
.collect(),
|
||||
};
|
||||
selections
|
||||
.into_iter()
|
||||
.map(|(label, family)| {
|
||||
let mut managed =
|
||||
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
|
||||
managed.address_family = family;
|
||||
managed.auth = spec.auth.clone();
|
||||
PreparedTarget { label, managed }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse one complete stdin target set after its line terminator was removed.
|
||||
@@ -277,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
||||
pub fn parse_stdin_target_set(
|
||||
record: &str,
|
||||
maximum_targets: usize,
|
||||
) -> Result<Vec<PreparedTarget>, String> {
|
||||
) -> Result<Vec<TargetSpec>, String> {
|
||||
if record == "[]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -288,5 +422,5 @@ pub fn parse_stdin_target_set(
|
||||
));
|
||||
}
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
target_specs_with_empty(&args, true)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.5.3"
|
||||
version = "0.6.0"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -59,9 +59,17 @@ Configuration is grouped by ownership:
|
||||
probe tracking policy.
|
||||
|
||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||
own ID, endpoint, and authentication inheritance/override; shared
|
||||
own ID, endpoint, address-family override, and authentication inheritance/override; shared
|
||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||
|
||||
`ManagedTargetConfig.address_family` defaults to `None`, inheriting the shared
|
||||
family policy. `Some(AddressFamily::Ipv4)`, `Some(AddressFamily::Ipv6)`, or
|
||||
`Some(AddressFamily::Any)` overrides it for that target. Every new generation
|
||||
resolves the original endpoint again; callers can constrain a hostname without
|
||||
replacing it with a resolved IP. Changing the family specification replaces
|
||||
that target's generation, including switching between inheritance and an equal
|
||||
explicit override. Hostname expansion and discovery are frontend policies.
|
||||
|
||||
Blocking receive policy belongs to `Client`:
|
||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
||||
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
|
||||
|
||||
use super::TargetId;
|
||||
|
||||
@@ -48,11 +48,16 @@ impl TargetAuth {
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint and authentication specification for one managed target.
|
||||
/// Endpoint, address-family and authentication specification for one managed target.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetConfig {
|
||||
pub id: TargetId,
|
||||
pub server_addr: String,
|
||||
/// Override the shared address-family policy. `None` inherits it.
|
||||
///
|
||||
/// The endpoint remains a hostname when supplied as one, so every new
|
||||
/// generation resolves it again using the effective family policy.
|
||||
pub address_family: Option<AddressFamily>,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
@@ -61,6 +66,7 @@ impl ManagedTargetConfig {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server_addr: server_addr.into(),
|
||||
address_family: None,
|
||||
auth: TargetAuth::Inherit,
|
||||
}
|
||||
}
|
||||
@@ -86,7 +92,7 @@ pub enum ManagedCompletionPolicy {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedClientConfig {
|
||||
/// Reusable session/socket configuration shared across target generations.
|
||||
/// Each target supplies its endpoint and resolves its authentication policy.
|
||||
/// Each target supplies its endpoint and resolves its family and authentication policies.
|
||||
pub client: ClientConfig,
|
||||
pub pacing: ManagedPacing,
|
||||
pub completion: ManagedCompletionPolicy,
|
||||
|
||||
@@ -397,6 +397,9 @@ fn build_task(
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||
let mut client_config = config.client.clone();
|
||||
client_config.address_family = target
|
||||
.address_family
|
||||
.unwrap_or(config.client.address_family);
|
||||
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedConfigError::InvalidTarget {
|
||||
|
||||
@@ -191,6 +191,9 @@ impl ManagedClientTask {
|
||||
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
||||
}
|
||||
let mut client_config = self.config.client.clone();
|
||||
client_config.address_family = target
|
||||
.address_family
|
||||
.unwrap_or(self.config.client.address_family);
|
||||
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedCommandApplyError::InvalidTarget {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-stats"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
description = "Statistics aggregation for irtt-client events"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+44
-1
@@ -58,6 +58,41 @@ once per interval instead. Final per-target summaries print in the order
|
||||
targets were supplied on the command line, not the order in which they
|
||||
finish or their labels sort alphabetically.
|
||||
|
||||
## ADDRESS FAMILY
|
||||
|
||||
By default, each target uses the first address returned by the system resolver,
|
||||
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
|
||||
`-6` / `--ipv6` to select IPv6 only. Hostnames are resolved when each session
|
||||
connects; these flags do not perform an extra discovery lookup. An explicit
|
||||
IP literal from the other family is rejected during preparation.
|
||||
|
||||
Use `--dual-stack` to compare IPv4 and IPv6 paths as separate measurements:
|
||||
|
||||
```sh
|
||||
irtt-client -6 host.example
|
||||
irtt-client --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||
```
|
||||
|
||||
For each hostname, the client discovers which address families the system
|
||||
resolver currently returns. IPv4 results create `cov/v4`, IPv6 results create
|
||||
`cov/v6`, and both create both targets, in that order. Without an alias,
|
||||
`host.example` becomes `host.example/v4` and/or `host.example/v6`. Multiple
|
||||
addresses within one family still produce only one target. Explicit IP literals
|
||||
keep their original label and remain single targets.
|
||||
|
||||
Each target has its own session, statistics, and failures. Its original hostname
|
||||
is retained: every new session resolves it again and selects the first address
|
||||
matching its family. Families are discovered only when a declaration is added,
|
||||
so a newly appearing family does not create another target automatically.
|
||||
Discovery failure or a successful lookup returning no addresses rejects the
|
||||
whole target set. A missing family is normal and simply creates no target for
|
||||
that family. Final labels must be unique, including generated `/v4` and `/v6`
|
||||
labels; choose another alias if one collides with an explicit label.
|
||||
|
||||
`-4`, `-6`, and `--dual-stack` are mutually exclusive. Expanded targets use
|
||||
ordinary multi-target pacing and each sends its own probes, so dual-stack can
|
||||
double the number of probes per interval compared with one target per hostname.
|
||||
|
||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||
|
||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||
@@ -75,6 +110,13 @@ are retired, unchanged target configurations retain their generation, and a
|
||||
changed address or HMAC setting creates a fresh generation. Under transient
|
||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||
|
||||
The same address-family preparation applies to initial and stdin targets.
|
||||
With `--dual-stack`, unchanged labels and endpoints retain their discovered
|
||||
families across records, including HMAC-only changes. Removing a declaration
|
||||
and later adding it again performs fresh discovery. New declarations are
|
||||
prepared asynchronously while existing measurements continue. A preparation
|
||||
error stops the stream gracefully without applying a partial set.
|
||||
|
||||
Commas frame targets in stdin records; escape a literal comma within one
|
||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||
@@ -107,7 +149,8 @@ revision to become applicable.
|
||||
|
||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||
desired set to 128 targets and retains at most 256 live target generations
|
||||
while replacements drain.
|
||||
while replacements drain. The 128-target limit applies after dual-stack
|
||||
expansion: a hostname producing both families consumes two target slots.
|
||||
|
||||
## FINITE VERSUS CONTINUOUS OPERATION
|
||||
|
||||
|
||||
+52
-6
@@ -21,6 +21,21 @@ Target syntax, multiple targets, and pacing are identical to
|
||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
||||
well; consult that manual for their meaning.
|
||||
|
||||
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
|
||||
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
|
||||
of a hostname its own measurement row and graph series. These switches are
|
||||
mutually exclusive. Explicit IP literals remain single targets; a literal
|
||||
conflicting with `-4` or `-6` is rejected before the dashboard starts.
|
||||
|
||||
```sh
|
||||
irtt-tui -4 uk-cov1.irtt.lochnair.net
|
||||
irtt-tui --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||
```
|
||||
|
||||
The second command creates `cov/v4` and/or `cov/v6` according to the system
|
||||
resolver's initial results, letting you compare the two paths directly.
|
||||
See the client manual's address-family section for discovery errors and labels.
|
||||
|
||||
## CONTINUOUS DEFAULT
|
||||
|
||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
||||
@@ -31,6 +46,23 @@ a finite run:
|
||||
irtt-tui <server> --duration 30s
|
||||
```
|
||||
|
||||
Continuous runs reconnect after target-local failures, peer closure, or a
|
||||
session completing because the server restricted its duration. After a
|
||||
1.5-second delay, one coalesced retry reopens completed targets while healthy
|
||||
sessions continue undisturbed. Even if every target fails, the TUI stays alive
|
||||
until you quit. Explicit stops, removed or replaced targets, and no-test
|
||||
completion do not trigger retries. Quitting cancels pending retry work.
|
||||
|
||||
With `--dual-stack`, a failed family reconnects independently while its healthy
|
||||
sibling continues. Reconnect resolves the original hostname again within the
|
||||
target's family. It does not discover additional families or pin the address
|
||||
returned by initial discovery.
|
||||
|
||||
Each reconnect keeps the same logical target row. Statistics reset when a new
|
||||
session generation is accepted, including attempts that fail before opening.
|
||||
Retained graph history and the age of the last actual reply remain; the graph
|
||||
leaves a gap between generations instead of connecting their samples.
|
||||
|
||||
## DASHBOARD
|
||||
|
||||
One dashboard is used for every target count and terminal size. It shows:
|
||||
@@ -88,7 +120,7 @@ redraws still work while the display is paused.
|
||||
The six graph metrics are effective RTT, raw RTT, adjusted RTT,
|
||||
client-to-server delay, server-to-client delay, and server processing time.
|
||||
Signed effective/adjusted RTT and one-way values are preserved. Graph windows
|
||||
range from 5 seconds to one hour and default to one minute. The footer shows
|
||||
range from 5 seconds to 24 hours and default to one minute. The footer shows
|
||||
controls appropriate to the currently open graph or details sheet.
|
||||
|
||||
## RETAINED HISTORY
|
||||
@@ -96,11 +128,20 @@ controls appropriate to the currently open graph or details sheet.
|
||||
The TUI keeps its own bounded presentation state on top of the statistics
|
||||
retention described in `irtt-client(1)`:
|
||||
|
||||
- Up to 100,000 graph samples per target.
|
||||
- Up to 500,000 graph samples per target.
|
||||
- Up to 80 recent status/log messages.
|
||||
|
||||
At 500,000 replies per target, retention is approximately 13h53m20s at
|
||||
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph
|
||||
samples store both clocks, six graph-ready values, and a session-boundary
|
||||
flag; latest details keep the full typed timing values and sequence number.
|
||||
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target
|
||||
at the cap, excluding statistics and temporary rendering buffers. Histories
|
||||
grow on demand and reserve exactly to the cap on their final growth step;
|
||||
they do not preallocate the full history for every target at startup.
|
||||
|
||||
`r` clears graph history for all targets and returns the viewport to
|
||||
live; it does not change the 100,000-sample cap itself. These are
|
||||
live; it does not change the 500,000-sample cap itself. These are
|
||||
presentation bounds on top of the client's own statistics retention (see
|
||||
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
|
||||
total memory is dominated by whichever of the two is larger for your target
|
||||
@@ -116,9 +157,14 @@ corrupt the display. Size a long finite TUI run from the figures in
|
||||
|
||||
## EXIT BEHAVIOR
|
||||
|
||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
||||
exit status follows the same peer-close and driver-failure rules as
|
||||
`irtt-client(1)`.
|
||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. In continuous
|
||||
mode, peer closure and target failures trigger reconnect rather than ending the
|
||||
run. A managed driver failure or an unexpected reconnect-update failure still
|
||||
ends the TUI with an error.
|
||||
|
||||
Finite runs do not reconnect. They complete after their target sessions end;
|
||||
a peer closure is a normal session ending, while a run whose targets all fail
|
||||
exits with an error.
|
||||
|
||||
## EXAMPLES
|
||||
|
||||
|
||||
Reference in new issue
Block a user