diff --git a/crates/irtt-app/Cargo.toml b/crates/irtt-app/Cargo.toml index 99c3d89..328ccd8 100644 --- a/crates/irtt-app/Cargo.toml +++ b/crates/irtt-app/Cargo.toml @@ -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 diff --git a/crates/irtt-app/src/cmd/client/args.rs b/crates/irtt-app/src/cmd/client/args.rs index 259419a..846cc2a 100644 --- a/crates/irtt-app/src/cmd/client/args.rs +++ b/crates/irtt-app/src/cmd/client/args.rs @@ -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 { + pub async fn prepare(&self) -> Result { 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 { diff --git a/crates/irtt-app/src/cmd/client/run.rs b/crates/irtt-app/src/cmd/client/run.rs index 323a55e..19bf3fa 100644 --- a/crates/irtt-app/src/cmd/client/run.rs +++ b/crates/irtt-app/src/cmd/client/run.rs @@ -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), + Targets(Vec), Stop(StdinStop), } @@ -286,9 +287,7 @@ fn read_stdin_target_sets( } 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; diff --git a/crates/irtt-app/src/cmd/tui/args.rs b/crates/irtt-app/src/cmd/tui/args.rs index 75ba262..cccc668 100644 --- a/crates/irtt-app/src/cmd/tui/args.rs +++ b/crates/irtt-app/src/cmd/tui/args.rs @@ -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 { + pub async fn prepare(&self) -> Result { prepare_managed_run( &self.common, self.duration, @@ -59,6 +59,7 @@ impl TuiArgs { stdin_controlled: false, }, ) + .await } pub fn is_continuous(&self) -> bool { diff --git a/crates/irtt-app/src/cmd/tui/run.rs b/crates/irtt-app/src/cmd/tui/run.rs index 4209476..a287a74 100644 --- a/crates/irtt-app/src/cmd/tui/run.rs +++ b/crates/irtt-app/src/cmd/tui/run.rs @@ -34,9 +34,10 @@ pub async fn run_tui( args: TuiArgs, mut shutdown: watch::Receiver, ) -> Result<(), Box> { - 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(()); diff --git a/crates/irtt-app/src/shared/client/args.rs b/crates/irtt-app/src/shared/client/args.rs index 699c439..3f53acb 100644 --- a/crates/irtt-app/src/shared/client/args.rs +++ b/crates/irtt-app/src/shared/client/args.rs @@ -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]")) diff --git a/crates/irtt-app/src/shared/client/config.rs b/crates/irtt-app/src/shared/client/config.rs index 35301e6..dd251ee 100644 --- a/crates/irtt-app/src/shared/client/config.rs +++ b/crates/irtt-app/src/shared/client/config.rs @@ -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 diff --git a/crates/irtt-app/src/shared/client/mod.rs b/crates/irtt-app/src/shared/client/mod.rs index be0b07f..c298440 100644 --- a/crates/irtt-app/src/shared/client/mod.rs +++ b/crates/irtt-app/src/shared/client/mod.rs @@ -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, }; diff --git a/crates/irtt-app/src/shared/client/prepare.rs b/crates/irtt-app/src/shared/client/prepare.rs index 077fac0..1d598b0 100644 --- a/crates/irtt-app/src/shared/client/prepare.rs +++ b/crates/irtt-app/src/shared/client/prepare.rs @@ -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, }) } diff --git a/crates/irtt-app/src/shared/client/targets.rs b/crates/irtt-app/src/shared/client/targets.rs index 0d3bc38..eaa9675 100644 --- a/crates/irtt-app/src/shared/client/targets.rs +++ b/crates/irtt-app/src/shared/client/targets.rs @@ -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, String> { target_specs_with_empty(targets, false) } -pub fn prepare_managed_targets(specs: Vec) -> Result, 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, +} + +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, + maximum_targets: Option, + ) -> Result<(Vec, 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 { + endpoint + .parse::() + .map(|addr| addr.ip()) + .ok() + .or_else(|| endpoint.parse().ok()) + .or_else(|| { + if endpoint.starts_with('[') && endpoint.ends_with(']') { + format!("{endpoint}:2112") + .parse::() + .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>, +) -> 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 { + 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) -> Result Result, String> { +) -> Result, 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::>(); - prepare_managed_targets(target_specs_with_empty(&args, true)?) + target_specs_with_empty(&args, true) } diff --git a/crates/irtt-client/README.md b/crates/irtt-client/README.md index bbb1bc1..9c59ef4 100644 --- a/crates/irtt-client/README.md +++ b/crates/irtt-client/README.md @@ -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. diff --git a/crates/irtt-client/src/managed/model.rs b/crates/irtt-client/src/managed/model.rs index 4215e3f..4518d5b 100644 --- a/crates/irtt-client/src/managed/model.rs +++ b/crates/irtt-client/src/managed/model.rs @@ -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, 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, diff --git a/crates/irtt-client/src/managed/task/control.rs b/crates/irtt-client/src/managed/task/control.rs index ad24508..f965ab3 100644 --- a/crates/irtt-client/src/managed/task/control.rs +++ b/crates/irtt-client/src/managed/task/control.rs @@ -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 { diff --git a/crates/irtt-client/src/managed/task/update.rs b/crates/irtt-client/src/managed/task/update.rs index f1bd036..719de56 100644 --- a/crates/irtt-client/src/managed/task/update.rs +++ b/crates/irtt-client/src/managed/task/update.rs @@ -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 { diff --git a/docs/irtt-client.md b/docs/irtt-client.md index 2a49159..83f2764 100644 --- a/docs/irtt-client.md +++ b/docs/irtt-client.md @@ -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 diff --git a/docs/irtt-tui.md b/docs/irtt-tui.md index 7586802..dcbffb4 100644 --- a/docs/irtt-tui.md +++ b/docs/irtt-tui.md @@ -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** @@ -38,6 +53,11 @@ 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