Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ metadata/update/DNS/TLS inspection modes, and executes requests via `src/http`.
- Rust error rendering uses rich diagnostics for common CLI/config errors, styling labels, flags/options, invalid values, file paths, and config line context while preserving plain-text `Display` output.
- Rust `-vvv` output prints config, DNS, TCP, and TTFB debug metadata through the central printer, including color policy and the blank response-header separator before formatted bodies.
- Rust `--timing` enables DNS pre-resolution timing and wraps reqwest's connector service so the waterfall includes DNS, TCP, TTFB, and Body phases. reqwest does not currently expose a separate TLS handshake duration, so Rust reports the combined TCP/TLS connector phase as TCP timing.
- The top-level app future in `src/app.rs` is heap-pinned before the shutdown-signal `tokio::select!`; do not move it back to `tokio::pin!` on the main stack because the combined async request/WebSocket/inspection state can overflow Windows' smaller main-thread stack even for metadata commands.
- Rust response body paging is controlled by `--pager auto|on|off` or `pager = ...`; `auto` routes terminal stdout through `less -FIRX`, `on` forces the pager, and `off` disables it. Image responses and output-file writes bypass the pager.
- Timeout duration parsing, Go-style duration formatting, elapsed request budgets, connect/DNS timeout caps, and shared `request timed out after ...` errors live in `src/duration.rs`; HTTP, WebSocket, DNS inspection, and TLS inspection paths should reuse `TimeoutBudget` instead of recomputing remaining time locally.
- Custom/pre-resolved DNS observes timeout budgets before the reqwest client is built: `--connect-timeout` bounds DNS resolution when set, otherwise DNS uses the remaining `--timeout` budget, and DoH lookup clients receive the same budget.
- Custom/pre-resolved DNS is scoped to the request URL; manual redirects that change scheme, host, or port rebuild the reqwest client and resolve the redirect target so `--dns-server`, `-vvv`, and `--timing` stay aligned with the actual target.
- Custom/pre-resolved DNS runs A and AAAA lookups concurrently for both UDP and DoH, preserving any successful records when the other family fails.
Expand Down
3 changes: 1 addition & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ pub async fn main_entry() -> i32 {
};

let signal_color = cli.color.clone();
let run = run(cli);
tokio::pin!(run);
let mut run = Box::pin(run(cli));
tokio::select! {
result = &mut run => match result {
Ok(code) => code,
Expand Down
2 changes: 1 addition & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ fn parse_duration_seconds(
let seconds = value
.parse::<f64>()
.map_err(|_| value_error(path, line_num, option, value, usage))?;
if !seconds.is_finite() || !(0.0..=crate::http::MAX_DURATION_SECONDS).contains(&seconds) {
if !seconds.is_finite() || !(0.0..=crate::duration::MAX_DURATION_SECONDS).contains(&seconds) {
return Err(value_error(path, line_num, option, value, usage));
}
Ok(seconds)
Expand Down
37 changes: 9 additions & 28 deletions src/dns/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::cli::Cli;
use crate::core::{self, Printer, Sequence};
use crate::dns::util::{dns_query_id, udp_dns_timeout};
use crate::dns::wire;
use crate::duration::{TimeoutBudget, duration_from_seconds};
use crate::error::{FetchError, write_error_with_color, write_warning_with_color};

const DNS_TYPE_A: u16 = wire::TYPE_A;
Expand Down Expand Up @@ -114,25 +115,17 @@ pub async fn execute(cli: &Cli) -> Result<i32, FetchError> {

let timeout = cli
.timeout
.map(|seconds| crate::http::duration_from_seconds("timeout", seconds))
.map(|seconds| duration_from_seconds("timeout", seconds))
.transpose()?;
let use_color = core::color_enabled(cli.color.as_deref(), std::io::stderr().is_terminal());
let inspected = if let Some(timeout) = timeout {
match tokio::time::timeout(
let inspected = TimeoutBudget::new(timeout)
.run(inspect_with_color(
&url,
cli.dns_server.as_deref(),
use_color,
timeout,
inspect_with_color(&url, cli.dns_server.as_deref(), use_color, Some(timeout)),
)
.await
{
Ok(result) => result,
Err(_) => Err(FetchError::Message(format!(
"request timed out after {}",
format_timeout(timeout)
))),
}
} else {
inspect_with_color(&url, cli.dns_server.as_deref(), use_color, None).await
};
))
.await;

match inspected {
Ok(output) => {
Expand Down Expand Up @@ -652,18 +645,6 @@ fn format_duration(duration: Duration) -> String {
format_go_duration_nanos(rounded)
}

fn format_timeout(timeout: Duration) -> String {
let seconds = timeout.as_secs_f64();
if timeout.subsec_nanos() == 0 {
format!("{}s", timeout.as_secs())
} else {
format!("{seconds:.3}s")
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
}

fn format_go_duration_nanos(nanos: u128) -> String {
if nanos < 1_000 {
return format!("{nanos}ns");
Expand Down
216 changes: 215 additions & 1 deletion src/duration.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::time::Duration;
use std::future::Future;
use std::time::{Duration, Instant};

use crate::error::FetchError;

const NANOS_PER_MICRO: u128 = 1_000;
const NANOS_PER_MILLI: u128 = 1_000_000;
Expand All @@ -7,6 +10,108 @@ const NANOS_PER_MINUTE: u128 = 60 * NANOS_PER_SECOND;
const NANOS_PER_HOUR: u128 = 60 * NANOS_PER_MINUTE;
const NANOS_PER_DAY: u128 = 24 * NANOS_PER_HOUR;

pub(crate) const MAX_DURATION_SECONDS: f64 = i64::MAX as f64 / 1_000_000_000_f64;

#[derive(Clone, Copy, Debug)]
pub(crate) struct TimeoutBudget {
timeout: Option<Duration>,
started_at: Instant,
}

impl TimeoutBudget {
pub(crate) fn new(timeout: Option<Duration>) -> Self {
Self::started_at(timeout, Instant::now())
}

pub(crate) fn started_at(timeout: Option<Duration>, started_at: Instant) -> Self {
Self {
timeout,
started_at,
}
}

pub(crate) fn for_connect(
connect_timeout: Option<Duration>,
request_timeout: Option<Duration>,
request_started_at: Instant,
) -> Result<Self, FetchError> {
let request_remaining = remaining_timeout(request_timeout, request_started_at)?;
Ok(Self::new(min_timeout(connect_timeout, request_remaining)))
}

pub(crate) fn timeout(self) -> Option<Duration> {
self.timeout
}

pub(crate) fn remaining(self) -> Result<Option<Duration>, FetchError> {
remaining_timeout(self.timeout, self.started_at)
}

pub(crate) fn timeout_error(self) -> FetchError {
request_timeout_error(self.timeout.expect("timeout checked by caller"))
}

pub(crate) async fn run<T>(
self,
future: impl Future<Output = Result<T, FetchError>>,
) -> Result<T, FetchError> {
let Some(remaining) = self.remaining()? else {
return future.await;
};
let started_at = Instant::now();
match tokio::time::timeout(remaining, future).await {
Ok(Err(err)) if started_at.elapsed() >= remaining && is_timeout_error(&err) => {
Err(self.timeout_error())
}
Ok(result) => result,
Err(_) => Err(self.timeout_error()),
}
}
}

pub(crate) fn duration_from_seconds(flag: &str, seconds: f64) -> Result<Duration, FetchError> {
if !seconds.is_finite() || !(0.0..=MAX_DURATION_SECONDS).contains(&seconds) {
return Err(format!("{flag} must be a non-negative number").into());
}
Ok(Duration::from_secs_f64(seconds))
}

pub(crate) fn remaining_timeout(
timeout: Option<Duration>,
started_at: Instant,
) -> Result<Option<Duration>, FetchError> {
let Some(timeout) = timeout else {
return Ok(None);
};
let elapsed = started_at.elapsed();
if elapsed >= timeout {
return Err(request_timeout_error(timeout));
}
Ok(Some(timeout - elapsed))
}

pub(crate) fn request_timeout_error(timeout: Duration) -> FetchError {
FetchError::Runtime(request_timeout_message(timeout))
}

pub(crate) fn request_timeout_message(timeout: Duration) -> String {
format!("request timed out after {}", format_go_duration(timeout))
}

pub(crate) fn format_go_duration(duration: Duration) -> String {
let nanos = duration.as_nanos();
if nanos < 1_000 {
return format!("{nanos}ns");
}
if nanos < 1_000_000 {
return format_duration_unit(nanos, 1_000, "us");
}
if nanos < 1_000_000_000 {
return format_duration_unit(nanos, 1_000_000, "ms");
}
format_duration_unit(nanos, 1_000_000_000, "s")
}

pub(crate) fn parse_duration_interval(value: &str) -> Option<Duration> {
let mut rest = value.trim();
if rest.is_empty() || rest.starts_with('-') {
Expand Down Expand Up @@ -88,6 +193,46 @@ fn duration_unit(value: &str) -> Option<(&'static str, u128)> {
None
}

fn min_timeout(left: Option<Duration>, right: Option<Duration>) -> Option<Duration> {
match (left, right) {
(Some(left), Some(right)) => Some(left.min(right)),
(Some(left), None) => Some(left),
(None, right) => right,
}
}

fn is_timeout_error(err: &FetchError) -> bool {
match err {
FetchError::Message(message) | FetchError::Runtime(message) => {
message.contains("timed out")
}
FetchError::Reqwest(err) => err.is_timeout(),
_ => false,
}
}

fn format_duration_unit(nanos: u128, unit_nanos: u128, suffix: &str) -> String {
let whole = nanos / unit_nanos;
let remainder = nanos % unit_nanos;
if remainder == 0 {
return format!("{whole}{suffix}");
}

let digits = match suffix {
"us" => 3_u32,
"ms" => 6_u32,
_ => 9_u32,
};
let scale = 10_u128.pow(digits);
let fraction_value = remainder * scale / unit_nanos;
let fraction = format!(
"{fraction_value:0width$}",
width = usize::try_from(digits).expect("small duration precision")
);
let fraction = fraction.trim_end_matches('0');
format!("{whole}.{fraction}{suffix}")
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -137,4 +282,73 @@ mod tests {
assert_eq!(parse_duration_interval("1sec"), None);
assert_eq!(parse_duration_interval("garbage"), None);
}

#[test]
fn duration_from_seconds_rejects_values_outside_supported_range() {
assert_eq!(
duration_from_seconds("timeout", 1.5).unwrap(),
Duration::from_millis(1500)
);

for seconds in [-1.0, f64::NAN, f64::INFINITY, 1e100] {
let err = duration_from_seconds("timeout", seconds).unwrap_err();
assert_eq!(err.to_string(), "timeout must be a non-negative number");
}
}

#[test]
fn timeout_budget_for_connect_uses_shortest_available_timeout() {
let budget = TimeoutBudget::for_connect(
Some(Duration::from_secs(5)),
Some(Duration::from_millis(250)),
Instant::now() - Duration::from_millis(100),
)
.unwrap();
let remaining = budget.remaining().unwrap().unwrap();

assert!(remaining <= Duration::from_millis(150));
assert!(remaining > Duration::from_millis(100));

let budget = TimeoutBudget::for_connect(
Some(Duration::from_millis(250)),
Some(Duration::from_secs(5)),
Instant::now(),
)
.unwrap();
assert!(budget.timeout().unwrap() <= Duration::from_millis(250));
}

#[test]
fn remaining_timeout_reports_expired_request_budget() {
let err = remaining_timeout(
Some(Duration::from_millis(10)),
Instant::now() - Duration::from_millis(20),
)
.unwrap_err();

assert_eq!(err.to_string(), "request timed out after 10ms");
}

#[test]
fn request_timeout_message_uses_go_duration_units() {
assert_eq!(
request_timeout_message(Duration::from_nanos(100)),
"request timed out after 100ns"
);
assert_eq!(
request_timeout_message(Duration::from_millis(50)),
"request timed out after 50ms"
);
}

#[test]
fn format_go_duration_matches_common_go_units() {
assert_eq!(format_go_duration(Duration::from_nanos(100)), "100ns");
assert_eq!(format_go_duration(Duration::from_nanos(1_500)), "1.5us");
assert_eq!(format_go_duration(Duration::from_nanos(1_500_000)), "1.5ms");
assert_eq!(
format_go_duration(Duration::from_nanos(1_500_000_000)),
"1.5s"
);
}
}
5 changes: 3 additions & 2 deletions src/grpc/reflection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use url::Url;

use crate::cli::Cli;
use crate::core;
use crate::duration::duration_from_seconds;
use crate::error::FetchError;
use crate::grpc::encoding::{self, MessageEncoding};
use crate::grpc::framing;
Expand All @@ -33,11 +34,11 @@ pub async fn execute_discovery(cli: &Cli) -> Result<i32, FetchError> {
let request_start = Instant::now();
let request_timeout = cli
.timeout
.map(|seconds| crate::http::duration_from_seconds("timeout", seconds))
.map(|seconds| duration_from_seconds("timeout", seconds))
.transpose()?;
let connect_timeout = cli
.connect_timeout
.map(|seconds| crate::http::duration_from_seconds("connect-timeout", seconds))
.map(|seconds| duration_from_seconds("connect-timeout", seconds))
.transpose()?;
crate::tls::install_default_crypto_provider();
let connect_timing = crate::http::client::ConnectionTiming::default();
Expand Down
Loading
Loading