diff --git a/Cargo.lock b/Cargo.lock index fce523b..913ef75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -778,6 +778,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -893,6 +899,7 @@ dependencies = [ "ctrlc", "futures-core", "hex", + "humantime", "irtt-client", "irtt-proto", "irtt-server", diff --git a/crates/irtt-app/Cargo.toml b/crates/irtt-app/Cargo.toml index 4328ac0..2ffafa7 100644 --- a/crates/irtt-app/Cargo.toml +++ b/crates/irtt-app/Cargo.toml @@ -42,9 +42,9 @@ doc = false [features] default = ["client", "server", "tui"] -client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] +client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] server = ["dep:irtt-server", "dep:tokio"] -tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] +tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] [dependencies] chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } @@ -53,6 +53,7 @@ clap-num = "=1.2.0" crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } futures-core = { version = "0.3.34", default-features = false, optional = true } hex = "0.4.3" +humantime = { version = "2.4.0", optional = true } ctrlc = { version = "3.5.2", features = ["termination"] } irtt-client = { workspace = true, optional = true } irtt-proto = { workspace = true, optional = true } diff --git a/crates/irtt-app/src/cmd/client/args.rs b/crates/irtt-app/src/cmd/client/args.rs index 71e121b..f936f0a 100644 --- a/crates/irtt-app/src/cmd/client/args.rs +++ b/crates/irtt-app/src/cmd/client/args.rs @@ -40,8 +40,8 @@ pub struct ClientArgs { long, default_value = "10s", value_parser = parse_test_duration, - help = "Test duration; use 0 for continuous mode", - long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF." + help = "Test duration (e.g. 2.5s, 1m30s, 1h); use 0 for continuous mode", + long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF." )] pub duration: Duration, diff --git a/crates/irtt-app/src/cmd/tui/args.rs b/crates/irtt-app/src/cmd/tui/args.rs index a99675f..8a7560d 100644 --- a/crates/irtt-app/src/cmd/tui/args.rs +++ b/crates/irtt-app/src/cmd/tui/args.rs @@ -35,8 +35,8 @@ pub struct TuiArgs { long, default_value = "0", value_parser = parse_test_duration, - help = "Test duration; use 0 for continuous mode", - long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode." + help = "Test duration (e.g. 2.5s, 1m30s, 1h); use 0 for continuous mode", + long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use 0 for continuous mode. The TUI defaults to continuous mode." )] pub duration: Duration, diff --git a/crates/irtt-app/src/shared/client/args.rs b/crates/irtt-app/src/shared/client/args.rs index cbdc8fe..1bcbf05 100644 --- a/crates/irtt-app/src/shared/client/args.rs +++ b/crates/irtt-app/src/shared/client/args.rs @@ -18,7 +18,7 @@ pub struct CommonClientArgs { #[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. + /// Positive probe interval (e.g. 2.5s, 1m30s, 500us, 100ns, 1h). #[arg(long, default_value = "1s", value_parser = parse_duration)] pub interval: Duration, @@ -200,62 +200,18 @@ fn parse_bind_addr(input: &str) -> Result { } pub fn parse_duration(input: &str) -> Result { - let (number, unit) = split_duration(input)?; - let value: u64 = number - .parse() - .map_err(|_| format!("invalid duration value {input:?}"))?; - if value == 0 { + let duration = parse_test_duration(input)?; + if duration.is_zero() { return Err("duration must be greater than zero".to_owned()); } - match unit { - "ms" => Ok(Duration::from_millis(value)), - "s" => Ok(Duration::from_secs(value)), - "m" => value - .checked_mul(60) - .map(Duration::from_secs) - .ok_or_else(|| "duration is too large".to_owned()), - _ => Err(format!( - "unsupported duration unit {unit:?}; use ms, s, or m" - )), - } + Ok(duration) } pub fn parse_test_duration(input: &str) -> Result { if input == "0" { return Ok(Duration::ZERO); } - let (number, unit) = split_duration(input)?; - let value: u64 = number - .parse() - .map_err(|_| format!("invalid duration value {input:?}"))?; - if value == 0 { - return Ok(Duration::ZERO); - } - match unit { - "ms" => Ok(Duration::from_millis(value)), - "s" => Ok(Duration::from_secs(value)), - "m" => value - .checked_mul(60) - .map(Duration::from_secs) - .ok_or_else(|| "duration is too large".to_owned()), - _ => Err(format!( - "unsupported duration unit {unit:?}; use ms, s, or m" - )), - } -} - -fn split_duration(input: &str) -> Result<(&str, &str), String> { - let split = input - .find(|ch: char| !ch.is_ascii_digit()) - .ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?; - let (number, unit) = input.split_at(split); - if number.is_empty() || unit.is_empty() { - return Err("duration must include a positive value and unit".to_owned()); - } - if number.starts_with('-') { - return Err("duration must be greater than zero".to_owned()); - } - Ok((number, unit)) + humantime::parse_duration(input).map_err(|error| error.to_string()) } pub fn parse_length(input: &str) -> Result { diff --git a/docs/irtt-client.md b/docs/irtt-client.md index 917f68d..1b6b647 100644 --- a/docs/irtt-client.md +++ b/docs/irtt-client.md @@ -174,6 +174,12 @@ expansion: a hostname producing both families consumes two target slots. ## FINITE VERSUS CONTINUOUS OPERATION +`--duration` and `--interval` accept fractional and composite durations, such as +`2.5s`, `1m30s`, and `1h`, as well as `ms`, `us` (or `µs`), and `ns` units +(e.g. `500us` or `100ns`). A nonzero value requires a unit. The interval must +be greater than zero; a zero test duration, including `0s`, selects continuous +mode. + `--duration` controls the run length: - Any nonzero duration (default `10s`) is a **finite** run: probing stops diff --git a/docs/irtt-tui.md b/docs/irtt-tui.md index 98cfe5b..4c6bc2d 100644 --- a/docs/irtt-tui.md +++ b/docs/irtt-tui.md @@ -40,7 +40,10 @@ See the client manual's address-family section for discovery errors and labels. Unlike `irtt-client`, **the TUI defaults to continuous mode** (`--duration 0`): it runs until you quit. Pass an explicit `--duration` for -a finite run: +a finite run. Both `--duration` and `--interval` use the +[client duration syntax](irtt-client.md#finite-versus-continuous-operation), +including fractional values (`2.5s`), composite values (`1m30s`), and units +from nanoseconds (`ns`) through hours (`h`): ```sh irtt-tui --duration 30s