diff --git a/Cargo.lock b/Cargo.lock index 4a5b8173..092ec43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2236,7 +2236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -3336,12 +3336,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hybrid-array" version = "0.4.13" @@ -5155,7 +5149,7 @@ dependencies = [ "oas3", "prettyplease", "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", "regex", "reqwest 0.13.4", @@ -5562,7 +5556,6 @@ dependencies = [ "clap", "futures", "hex", - "humantime", "k256", "libp2p", "pluto-app", @@ -5579,7 +5572,7 @@ dependencies = [ "pluto-ssz", "pluto-testutil", "pluto-tracing", - "quick-xml 0.39.4", + "quick-xml", "rand 0.8.6", "reqwest 0.13.4", "serde", @@ -6413,16 +6406,6 @@ dependencies = [ "unsigned-varint 0.8.0", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.41.0" diff --git a/Cargo.toml b/Cargo.toml index 4264df4f..3871d3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,6 @@ futures-timer = "3.0" backon = "1.6.0" hex = { version = "0.4.3" } hex-literal = "0.4" -humantime = "2.1" prost = "0.14" prost-build = "0.14" prost-types = "0.14" @@ -116,7 +115,7 @@ flate2 = "1.1" wiremock = "0.6" tower = "0.5" sysinfo = "0.33" -quick-xml = { version = "0.39", features = ["serialize"] } +quick-xml = { version = "0.41", features = ["serialize"] } # Crates in the workspace pluto-app = { path = "crates/app" } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d87cf740..a821544b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -15,7 +15,6 @@ clap.workspace = true thiserror.workspace = true k256.workspace = true hex.workspace = true -humantime.workspace = true tokio.workspace = true pluto-app.workspace = true pluto-eth1wrap.workspace = true diff --git a/crates/cli/src/commands/relay.rs b/crates/cli/src/commands/relay.rs index 79a0cfc7..4ae7317d 100644 --- a/crates/cli/src/commands/relay.rs +++ b/crates/cli/src/commands/relay.rs @@ -128,7 +128,7 @@ pub struct RelayDataDirArgs { pub struct RelayRelayArgs { #[arg( long = "http-address", - env = "PLUTO_HTTP_ADDRESS", + env = "CHARON_HTTP_ADDRESS", default_value = "127.0.0.1:3640", help = "Listening address (ip and port) for the relay http server serving runtime ENR." )] @@ -136,7 +136,7 @@ pub struct RelayRelayArgs { #[arg( long = "auto-p2pkey", - env = "PLUTO_AUTO_P2PKEY", + env = "CHARON_AUTO_P2PKEY", default_value_t = true, help = "Automatically generate and persist a p2p key if one does not exist." )] @@ -144,7 +144,7 @@ pub struct RelayRelayArgs { #[arg( long = "p2p-relay-loglevel", - env = "PLUTO_P2P_RELAY_LOGLEVEL", + env = "CHARON_P2P_RELAY_LOGLEVEL", default_value = "", help = "Libp2p circuit relay log level. E.g., debug, info, warn, error." )] @@ -154,7 +154,7 @@ pub struct RelayRelayArgs { // If so, decrease defaults after this has been addressed #[arg( long = "p2p-max-reservations", - env = "PLUTO_P2P_MAX_RESERVATIONS", + env = "CHARON_P2P_MAX_RESERVATIONS", default_value_t = 512, help = "Updates max circuit reservations per peer (each valid for 30min)" )] @@ -162,7 +162,7 @@ pub struct RelayRelayArgs { #[arg( long = "p2p-max-connections", - env = "PLUTO_P2P_MAX_CONNECTIONS", + env = "CHARON_P2P_MAX_CONNECTIONS", default_value_t = 16384, help = "Libp2p maximum number of peers that can connect to this relay." )] @@ -170,7 +170,7 @@ pub struct RelayRelayArgs { #[arg( long = "p2p-advertise-private-addresses", - env = "PLUTO_P2P_ADVERTISE_PRIVATE_ADDRESSES", + env = "CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES", help = "Enable advertising of libp2p auto-detected private addresses. This doesn't affect manually provided p2p-external-ip/hostname." )] pub advertise_priv: bool, @@ -180,14 +180,14 @@ pub struct RelayRelayArgs { pub struct RelayDebugMonitoringArgs { #[arg( long = "monitoring-address", - env = "PLUTO_MONITORING_ADDRESS", + env = "CHARON_MONITORING_ADDRESS", help = "Listening address (ip and port) for the monitoring API (prometheus)." )] pub monitor_addr: Option, #[arg( long = "debug-address", - env = "PLUTO_DEBUG_ADDRESS", + env = "CHARON_DEBUG_ADDRESS", default_value = "", help = "Listening address (ip and port) for the pprof and QBFT debug API. It is not enabled by default." )] @@ -198,7 +198,7 @@ pub struct RelayDebugMonitoringArgs { pub struct RelayP2PArgs { #[arg( long = "p2p-relays", - env = "PLUTO_P2P_RELAYS", + env = "CHARON_P2P_RELAYS", value_delimiter = ',', default_values_t = pluto_p2p::config::DEFAULT_RELAYS.map(String::from), help = "Comma-separated list of libp2p relay URLs or multiaddrs." @@ -207,21 +207,21 @@ pub struct RelayP2PArgs { #[arg( long = "p2p-external-ip", - env = "PLUTO_P2P_EXTERNAL_IP", + env = "CHARON_P2P_EXTERNAL_IP", help = "The IP address advertised by libp2p. This may be used to advertise an external IP." )] pub external_ip: Option, #[arg( long = "p2p-external-hostname", - env = "PLUTO_P2P_EXTERNAL_HOSTNAME", + env = "CHARON_P2P_EXTERNAL_HOSTNAME", help = "The DNS hostname advertised by libp2p. This may be used to advertise an external DNS." )] pub external_host: Option, #[arg( long = "p2p-tcp-address", - env = "PLUTO_P2P_TCP_ADDRESS", + env = "CHARON_P2P_TCP_ADDRESS", value_delimiter = ',', help = "Comma-separated list of listening TCP addresses (ip and port) for libP2P traffic. Empty default doesn't bind to local port therefore only supports outgoing connections." )] @@ -229,7 +229,7 @@ pub struct RelayP2PArgs { #[arg( long = "p2p-udp-address", - env = "PLUTO_P2P_UDP_ADDRESS", + env = "CHARON_P2P_UDP_ADDRESS", value_delimiter = ',', help = "Comma-separated list of listening UDP addresses (ip and port) for libP2P traffic. Empty default doesn't bind to local port therefore only supports outgoing connections." )] @@ -237,7 +237,7 @@ pub struct RelayP2PArgs { #[arg( long = "p2p-disable-reuseport", - env = "PLUTO_P2P_DISABLE_REUSEPORT", + env = "CHARON_P2P_DISABLE_REUSEPORT", default_value_t = false, help = "Disables TCP port reuse for outgoing libp2p connections." )] @@ -248,7 +248,7 @@ pub struct RelayP2PArgs { pub struct RelayLogFlags { #[arg( long = "log-format", - env = "PLUTO_LOG_FORMAT", + env = "CHARON_LOG_FORMAT", default_value = "console", help = "Log format; console, logfmt or json" )] @@ -256,7 +256,7 @@ pub struct RelayLogFlags { #[arg( long = "log-level", - env = "PLUTO_LOG_LEVEL", + env = "CHARON_LOG_LEVEL", default_value = "info", help = "Log level; debug, info, warn or error" )] @@ -267,7 +267,7 @@ pub struct RelayLogFlags { #[arg( long = "log-output-path", - env = "PLUTO_LOG_OUTPUT_PATH", + env = "CHARON_LOG_OUTPUT_PATH", help = "Path in which to write on-disk logs." )] pub log_output_path: Option, @@ -277,7 +277,7 @@ pub struct RelayLogFlags { pub struct RelayLokiArgs { #[arg( long = "loki-addresses", - env = "PLUTO_LOKI_ADDRESSES", + env = "CHARON_LOKI_ADDRESSES", value_delimiter = ',', help = "Enables sending of logfmt structured logs to these Loki log aggregation server addresses. This is in addition to normal stderr logs." )] @@ -285,7 +285,7 @@ pub struct RelayLokiArgs { #[arg( long = "loki-service", - env = "PLUTO_LOKI_SERVICE", + env = "CHARON_LOKI_SERVICE", default_value = "pluto", help = "Service label sent with logs to Loki." )] diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 6635fe43..2a1eec1a 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -62,7 +62,7 @@ pub struct TestBeaconArgs { #[arg( long = "load-test-duration", default_value = "5s", - value_parser = humantime::parse_duration, + value_parser = crate::duration::parse_go_duration, help = "Time to keep running the load tests. For each second a new continuous ping instance is spawned." )] pub load_test_duration: StdDuration, diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index 933d12d2..d95efb17 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -46,7 +46,7 @@ pub struct TestConfigArgs { #[arg( long, default_value = "1h", - value_parser = humantime::parse_duration, + value_parser = crate::duration::parse_go_duration, help = "Execution timeout for all tests" )] pub timeout: StdDuration, diff --git a/crates/cli/src/commands/test/peers.rs b/crates/cli/src/commands/test/peers.rs index 1b2160dd..d1d41d1c 100644 --- a/crates/cli/src/commands/test/peers.rs +++ b/crates/cli/src/commands/test/peers.rs @@ -121,7 +121,7 @@ pub struct TestPeersArgs { #[arg( long = "keep-alive", default_value = "30m", - value_parser = humantime::parse_duration + value_parser = crate::duration::parse_go_duration )] pub keep_alive: Duration, @@ -130,7 +130,7 @@ pub struct TestPeersArgs { #[arg( long = "load-test-duration", default_value = "30s", - value_parser = humantime::parse_duration + value_parser = crate::duration::parse_go_duration )] pub load_test_duration: Duration, @@ -138,7 +138,7 @@ pub struct TestPeersArgs { #[arg( long = "direct-connection-timeout", default_value = "2m", - value_parser = humantime::parse_duration + value_parser = crate::duration::parse_go_duration )] pub direct_connection_timeout: Duration, diff --git a/crates/cli/src/commands/test/validator.rs b/crates/cli/src/commands/test/validator.rs index a1b46273..d3719577 100644 --- a/crates/cli/src/commands/test/validator.rs +++ b/crates/cli/src/commands/test/validator.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; use super::{ AllCategoriesResult, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, TestVerdict, + must_output_to_file_on_quiet, }; use crate::{ duration::Duration as CliDuration, @@ -75,7 +76,7 @@ pub struct TestValidatorArgs { #[arg( long = "load-test-duration", default_value = "5s", - value_parser = humantime::parse_duration, + value_parser = crate::duration::parse_go_duration, help = "Time to keep running the load tests. For each second a new continuous ping instance is spawned." )] pub load_test_duration: Duration, @@ -87,6 +88,8 @@ pub async fn run( writer: &mut dyn Write, ct: CancellationToken, ) -> Result { + must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; + tracing::info!("Starting validator client test"); // Get and filter test cases @@ -315,3 +318,43 @@ async fn ping_continuously( tokio::time::sleep(Duration::from_millis(sleep_ms)).await; } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration as StdDuration; + + fn default_test_config() -> TestConfigArgs { + TestConfigArgs { + output_json: String::new(), + quiet: false, + test_cases: None, + timeout: StdDuration::from_secs(60), + publish: false, + publish_addr: String::new(), + publish_private_key_file: std::path::PathBuf::new(), + } + } + + fn default_validator_args() -> TestValidatorArgs { + TestValidatorArgs { + test_config: default_test_config(), + api_address: "127.0.0.1:3600".to_string(), + load_test_duration: StdDuration::from_secs(1), + } + } + + #[tokio::test] + async fn run_quiet_without_output_json_returns_error() { + let mut args = default_validator_args(); + args.test_config.quiet = true; + let mut output = Vec::new(); + let err = run(args, &mut output, CancellationToken::new()) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("on --quiet, an --output-json is required") + ); + } +} diff --git a/crates/cli/src/duration.rs b/crates/cli/src/duration.rs index f5c43cfa..3501491f 100644 --- a/crates/cli/src/duration.rs +++ b/crates/cli/src/duration.rs @@ -7,6 +7,8 @@ const NANOSECOND: u64 = 1; const MICROSECOND: u64 = 1000 * NANOSECOND; const MILLISECOND: u64 = 1000 * MICROSECOND; const SECOND: u64 = 1000 * MILLISECOND; +const MINUTE: u64 = 60 * SECOND; +const HOUR: u64 = 60 * MINUTE; /// Custom Duration wrapper with JSON serialization. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -84,15 +86,183 @@ impl std::str::FromStr for Duration { type Err = String; fn from_str(s: &str) -> Result { - // Try parsing as integer (nanoseconds) + // Try parsing as integer (nanoseconds), mirroring Charon's + // `UnmarshalText` `strconv.ParseInt` branch. if let Ok(nanos) = s.parse::() { return Ok(Self::new(StdDuration::from_nanos(nanos))); } - // Use humantime for duration string parsing - humantime::parse_duration(s) - .map(Self::new) - .map_err(|e| e.to_string()) + parse_go_duration(s).map(Self::new) + } +} + +/// Maximum duration representable by Go's `time.Duration` (`1<<63 - 1` ns). +const GO_MAX_DURATION_NANOS: u64 = (1 << 63) - 1; + +/// Parses a duration string the way Go's `time.ParseDuration` does. +/// +/// Accepted units: `ns`, `us`/`µs`/`μs`, `ms`, `s`, `m`, `h`. Multiple +/// `` segments concatenate (`1h30m`), numbers may be +/// fractional (`1.5h`), a leading `+` is allowed, and the bare string `"0"` +/// is a special case with no unit required. +// PARITY: Go time.ParseDuration, used by charon v1.7.1 cmd/duration.go +// UnmarshalText for all CLI duration flags. Deviation: a leading `-` is +// rejected (Go accepts negative durations) because `std::time::Duration` +// is unsigned and every flag using this parser is a non-negative +// timeout/delay. +pub fn parse_go_duration(s: &str) -> Result { + let orig = s; + let mut rest = s; + + // Consume [-+]? + let mut neg = false; + if let Some(stripped) = rest.strip_prefix(['-', '+']) { + neg = rest.starts_with('-'); + rest = stripped; + } + + // Special case: if all that is left is "0", this is zero. + if rest == "0" { + return Ok(StdDuration::ZERO); + } + + if rest.is_empty() { + return Err(format!("invalid duration {orig:?}")); + } + + if neg { + return Err(format!("negative durations are not supported: {orig}")); + } + + let mut total: u64 = 0; + while !rest.is_empty() { + // The next character must be [0-9.] + if !rest.starts_with(|c: char| c == '.' || c.is_ascii_digit()) { + return Err(format!("invalid duration {orig:?}")); + } + + // Consume [0-9]* + let (v, after_int) = + leading_int(rest).map_err(|()| format!("invalid duration {orig:?}"))?; + let pre = after_int.len() != rest.len(); + rest = after_int; + + // Consume (\.[0-9]*)? + let mut frac: u64 = 0; + let mut scale: f64 = 1.0; + let mut post = false; + if let Some(after_dot) = rest.strip_prefix('.') { + let (f, sc, after_frac) = leading_fraction(after_dot); + frac = f; + scale = sc; + post = after_frac.len() != after_dot.len(); + rest = after_frac; + } + + if !pre && !post { + // No digits (e.g. ".s"). + return Err(format!("invalid duration {orig:?}")); + } + + // Consume unit: everything up to the next digit or '.'. + let unit_len = rest + .find(|c: char| c == '.' || c.is_ascii_digit()) + .unwrap_or(rest.len()); + if unit_len == 0 { + return Err(format!("missing unit in duration {orig:?}")); + } + let unit_str = &rest[..unit_len]; + rest = &rest[unit_len..]; + let unit = unit_scale(unit_str) + .ok_or_else(|| format!("unknown unit {unit_str:?} in duration {orig:?}"))?; + + let mut nanos = v + .checked_mul(unit) + .filter(|&n| n <= GO_MAX_DURATION_NANOS) + .ok_or_else(|| format!("invalid duration {orig:?}"))?; + if frac > 0 { + // Match Go: float64 is nanosecond-accurate for fractions of the + // largest unit (hours). + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let frac_nanos = (frac as f64 * (unit as f64 / scale)) as u64; + nanos = nanos + .checked_add(frac_nanos) + .filter(|&n| n <= GO_MAX_DURATION_NANOS) + .ok_or_else(|| format!("invalid duration {orig:?}"))?; + } + + total = total + .checked_add(nanos) + .filter(|&t| t <= GO_MAX_DURATION_NANOS) + .ok_or_else(|| format!("invalid duration {orig:?}"))?; + } + + Ok(StdDuration::from_nanos(total)) +} + +/// Consumes leading `[0-9]*` from `s`. Errors on overflow. +fn leading_int(s: &str) -> Result<(u64, &str), ()> { + let mut x: u64 = 0; + let mut end = 0; + for (i, &b) in s.as_bytes().iter().enumerate() { + if !b.is_ascii_digit() { + break; + } + x = x + .checked_mul(10) + .and_then(|y| y.checked_add(u64::from(b.saturating_sub(b'0')))) + .filter(|&y| y <= GO_MAX_DURATION_NANOS) + .ok_or(())?; + end = i.saturating_add(1); + } + Ok((x, &s[end..])) +} + +/// Consumes leading `[0-9]*` from `s` as the value and scale of its decimal +/// fraction. Ignores digits beyond the overflow point, like Go. +fn leading_fraction(s: &str) -> (u64, f64, &str) { + let mut x: u64 = 0; + let mut scale: f64 = 1.0; + let mut overflow = false; + let mut end = 0; + for (i, &b) in s.as_bytes().iter().enumerate() { + if !b.is_ascii_digit() { + break; + } + end = i.saturating_add(1); + if overflow { + continue; + } + match x + .checked_mul(10) + .and_then(|y| y.checked_add(u64::from(b.saturating_sub(b'0')))) + .filter(|&y| y <= GO_MAX_DURATION_NANOS) + { + Some(y) => { + x = y; + scale *= 10.0; + } + None => overflow = true, + } + } + (x, scale, &s[end..]) +} + +/// Returns the nanosecond scale of a Go duration unit, or `None` if unknown. +/// Go accepts both the micro sign `µ` (U+00B5) and the Greek mu `μ` (U+03BC). +fn unit_scale(unit: &str) -> Option { + match unit { + "ns" => Some(NANOSECOND), + "us" | "\u{00b5}s" | "\u{03bc}s" => Some(MICROSECOND), + "ms" => Some(MILLISECOND), + "s" => Some(SECOND), + "m" => Some(MINUTE), + "h" => Some(HOUR), + _ => None, } } @@ -344,11 +514,60 @@ mod tests { } #[test_case("second"; "text_string")] + #[test_case("1d"; "days_unit")] + #[test_case("1w"; "weeks_unit")] fn test_from_str_error(input: &str) { let result = input.parse::(); assert!(result.is_err()); } + #[test_case("1ns", StdDuration::from_nanos(1); "one_nanosecond")] + #[test_case("1us", StdDuration::from_micros(1); "one_microsecond_ascii")] + #[test_case("1µs", StdDuration::from_micros(1); "one_microsecond_micro_sign")] + #[test_case("1\u{03bc}s", StdDuration::from_micros(1); "one_microsecond_greek_mu")] + #[test_case("1ms", StdDuration::from_millis(1); "one_millisecond")] + #[test_case("1s", StdDuration::from_secs(1); "one_second")] + #[test_case("2m", StdDuration::from_secs(120); "two_minutes")] + #[test_case("1h", StdDuration::from_secs(3600); "one_hour")] + #[test_case("1m0s", StdDuration::from_secs(60); "multi_segment_dkg_default")] + #[test_case("1h30m", StdDuration::from_secs(5400); "multi_segment_hour_minute")] + #[test_case("24h0m0s", StdDuration::from_secs(24 * 3600); "charon_round_trip")] + #[test_case("1.5h", StdDuration::from_secs(5400); "fractional_hours")] + #[test_case("2.5s", StdDuration::from_millis(2500); "fractional_seconds")] + #[test_case("0.5s", StdDuration::from_millis(500); "fraction_below_one")] + #[test_case(".5s", StdDuration::from_millis(500); "fraction_no_integer_part")] + #[test_case("100ns", StdDuration::from_nanos(100); "hundred_nanoseconds")] + #[test_case("+5s", StdDuration::from_secs(5); "leading_plus")] + #[test_case("0", StdDuration::ZERO; "bare_zero_special_case")] + #[test_case("-0", StdDuration::ZERO; "negative_zero_special_case")] + #[test_case("0s", StdDuration::ZERO; "zero_seconds")] + fn test_parse_go_duration(input: &str, expected: StdDuration) { + assert_eq!(parse_go_duration(input).unwrap(), expected); + } + + #[test_case("1d", "unknown unit"; "days_rejected")] + #[test_case("1w", "unknown unit"; "weeks_rejected")] + #[test_case("1y", "unknown unit"; "years_rejected")] + #[test_case("1M", "unknown unit"; "months_rejected")] + #[test_case("5sec", "unknown unit"; "humantime_spelling_rejected")] + #[test_case("-5s", "negative durations"; "negative_seconds")] + #[test_case("-1h", "negative durations"; "negative_hours")] + #[test_case("1", "missing unit"; "unitless_integer")] + #[test_case("1.5", "missing unit"; "unitless_fraction")] + #[test_case("s", "invalid duration"; "unit_without_number")] + #[test_case("", "invalid duration"; "empty")] + #[test_case(".s", "invalid duration"; "dot_without_digits")] + #[test_case("abc", "invalid duration"; "letters")] + #[test_case("not-a-duration", "invalid duration"; "dashed_text")] + #[test_case("9999999999999999999h", "invalid duration"; "overflow")] + fn test_parse_go_duration_error(input: &str, expected_msg: &str) { + let err = parse_go_duration(input).unwrap_err(); + assert!( + err.contains(expected_msg), + "error {err:?} does not contain {expected_msg:?}" + ); + } + #[test_case(StdDuration::from_micros(15151), StdDuration::from_millis(15); "15_151_milliseconds")] #[test_case(StdDuration::from_nanos(15151515), StdDuration::from_millis(15); "15_151515_milliseconds")] #[test_case(StdDuration::from_micros(2344444), StdDuration::from_millis(2340); "2_344444_seconds")]