Merge branch 'feat/dual-stack'
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m7s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m7s
This commit is contained in:
16 files changed
+334
-44
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -34,9 +34,10 @@ 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(());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user