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
816 changes: 394 additions & 422 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ serde_json = { version = "1.0", features = ["preserve_order"] }
indexmap = "2.13"
clap_complete = "4.5"
jiff = "0.2"
httpdate = "1.0"
ctrlc = { version = "3.5", features = ["termination"] }
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ Options:
Test upload speed only
--generate-completion <COMPLETION>
Generate shell completion script for the specified shell [possible values: bash, elvish, fish, powershell, zsh]
--server <SERVER>
Base URL of a compatible Cloudflare speed-test service [default: https://speed.cloudflare.com]
--max-duration <MAX_DURATION>
Maximum total run duration in seconds [default: 120]
--max-retry-wait <MAX_RETRY_WAIT>
Maximum cumulative retry wait in seconds [default: 30]
-h, --help
Print help
-V, --version
Expand All @@ -60,6 +66,40 @@ Example usage:
Example with json-pretty output:
[![asciicast](https://asciinema.org/a/P6IUAADtaCq3bT18GbYVHmksA.svg)](https://asciinema.org/a/P6IUAADtaCq3bT18GbYVHmksA)

### Run limits and failures

Runs have a shared 120-second measurement deadline and a 30-second cumulative retry-wait budget by default. Both limits cover the entire run, including metadata, latency, and both transfer directions. Each request is limited to the smaller of 30 seconds and the remaining run time.

```sh
cfspeedtest --max-duration 60 --max-retry-wait 10 -o json
```

`--max-duration` and `--max-retry-wait` are in seconds. Setting the retry-wait budget to zero prevents positive retry waits. The client accepts both seconds and HTTP dates in `Retry-After`; when the requested delay exceeds either remaining budget, it stops and retains completed measurements instead of retrying early. `--disable-dynamic-max-payload-size` does not disable these limits.

Ctrl-C stops new measurements and interrupts retry waits. An in-flight blocking request may take up to its remaining request timeout to finish; completed samples are retained. On Unix, SIGTERM and SIGHUP use the same graceful cancellation path.

| Exit code | Meaning |
| --- | --- |
| 0 | Complete: every attempted payload reached its successful-sample target and all enabled latency probes succeeded. Payloads omitted by normal adaptive stopping do not make a run partial. |
| 1 | Failed: no valid throughput samples were collected, or CLI initialization failed. |
| 2 | Invalid command-line arguments. |
| 3 | Partial: some valid throughput samples exist, but a sample target was missed or a run limit stopped testing. |
| 130 | Cancelled; any completed measurements are retained. |

Metadata is optional: an unavailable or invalid trace response is reported as an error, but does not by itself make a successful measurement run fail. Failed samples that are replaced by successful retries remain in the error history without making a completed run partial. Operational diagnostics go to stderr in every CLI output mode.

JSON includes `status`, `stop_reason` (`deadline`, `retry_budget`, `cancelled`, or null), and structured `errors` with stage, payload size, HTTP status when available, and reason. Missing metadata is null. `latency_measurement` is always present and includes `status`, `attempts`, `successes`, `target_samples`, and `errors`. Its summary values are null when no valid samples exist; human output uses N/A. `--nr-latency-tests 0` explicitly disables latency testing. CSV retains its throughput-only columns; use the exit code and stderr to detect failures, or JSON for the full outcome.

Downloads must deliver exactly the requested number of bytes without body-read errors. Upload response-body errors also invalidate the sample, while upload timing still stops at response headers. Quartiles use the median of each half, excluding the middle observation for odd sample counts; a singleton uses that observation for every summary statistic.

`--server <URL>` selects a compatible service exposing `/cdn-cgi/trace`, `/__down?bytes=...`, and `/__up`. It defaults to `https://speed.cloudflare.com` and also supports local HTTP fixtures for testing.

### Library outcomes

`speed_test_with_config(client, options, RunConfig)` returns a `SpeedTestReport` with metadata, latency, throughput samples, attempt counts, errors, and an exit-code policy. Set `options.output_format = OutputFormat::None` to consume it without printing. `RunConfig` supplies the endpoint, time budgets, and a shared cancellation flag; library calls never install signal handlers or exit the host process.

`run_latency_report` and `try_test_latency` provide explicit latency outcomes. The existing `speed_test` and tuple/floating-point functions remain available. Missing latency from `test_latency` or `run_latency_test` now uses NaN instead of the misleading zero fallback. `fetch_metadata` returns `MeasurementError` so it can report invalid metadata as well as HTTP/transport errors.

### Shell Completion

`cfspeedtest` supports generating shell completion scripts. Use the `--generate-completion` flag followed by your shell name (e.g., `bash`, `zsh`, `fish`, `powershell`, `elvish`).
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod boxplot;
pub mod measurements;
pub mod progress;
pub mod run;
pub mod speedtest;
use std::fmt;
use std::fmt::Display;
Expand Down
63 changes: 57 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,42 @@ use clap_complete::generate;
use std::io;
use std::net::IpAddr;

use speedtest::speed_test;
use cfspeedtest::run::RunConfig;
use speedtest::speed_test_with_config;
use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::time::Duration;

#[derive(Parser)]
#[command(version, about = "Unofficial CLI for speed.cloudflare.com")]
struct CliOptions {
#[command(flatten)]
options: SpeedTestCLIOptions,
/// Base URL of a compatible Cloudflare speed-test service
#[arg(long, default_value = "https://speed.cloudflare.com")]
server: reqwest::Url,
/// Maximum total run duration in seconds
#[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u64).range(1..=86400))]
max_duration: u64,
/// Maximum cumulative retry wait in seconds
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(0..=86400))]
max_retry_wait: u64,
}

fn print_completions<G: clap_complete::Generator>(gen: G, cmd: &mut clap::Command) {
generate(gen, cmd, cmd.get_name().to_string(), &mut io::stdout());
}

fn main() {
fn main() -> ExitCode {
env_logger::init();
let options = SpeedTestCLIOptions::parse();
let cli = CliOptions::parse();
let options = cli.options;

if let Some(generator) = options.completion {
let mut cmd = SpeedTestCLIOptions::command();
let mut cmd = CliOptions::command();
eprintln!("Generating completion script for {generator}...");
print_completions(generator, &mut cmd);
return;
return ExitCode::SUCCESS;
}

if options.output_format == OutputFormat::StdOut {
Expand All @@ -45,8 +66,38 @@ fn main() {
.cookie_store(true)
.build();
}
speed_test(
let config = RunConfig {
base_url: cli.server.as_str().trim_end_matches('/').to_string(),
max_duration: Duration::from_secs(cli.max_duration),
max_retry_wait: Duration::from_secs(cli.max_retry_wait),
..RunConfig::default()
};
let cancelled = config.cancelled.clone();
if let Err(error) = ctrlc::set_handler(move || {
cancelled.store(true, Ordering::SeqCst);
}) {
eprintln!("Failed to install cancellation handler: {error}");
return ExitCode::FAILURE;
}
let report = speed_test_with_config(
client.expect("Failed to initialize reqwest client"),
options,
config,
);
for error in &report.errors {
let payload = error
.payload_size
.map_or(String::new(), |bytes| format!(" ({bytes} bytes)"));
eprintln!("{}{}: {}", error.stage, payload, error.error);
}
if let Some(reason) = report.stop_reason {
eprintln!("Run stopped: {reason:?}");
}
if report.exit_code() != 0 {
eprintln!(
"Run status: {:?}; completed measurements are retained in the output",
report.status
);
}
ExitCode::from(report.exit_code())
}
75 changes: 58 additions & 17 deletions src/measurements.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::boxplot;
use crate::run::SpeedTestReport;
use crate::speedtest::Metadata;
use crate::speedtest::TestType;
use crate::OutputFormat;
Expand Down Expand Up @@ -60,14 +61,13 @@ impl Display for Measurement {
}

pub(crate) fn log_measurements(
measurements: &[Measurement],
payload_attempt_stats: &[PayloadAttemptStats],
latency_measurement: Option<&LatencyMeasurement>,
report: &SpeedTestReport,
payload_sizes: Vec<usize>,
verbose: bool,
output_format: OutputFormat,
metadata: Option<&Metadata>,
) {
let measurements = &report.measurements;
let payload_attempt_stats = &report.payload_attempt_stats;
if output_format == OutputFormat::StdOut {
println!("\nSummary Statistics");
if verbose {
Expand Down Expand Up @@ -104,12 +104,12 @@ pub(crate) fn log_measurements(
wtr.flush().unwrap();
}
OutputFormat::Json => {
let output = compose_output_json(&stat_measurements, latency_measurement, metadata);
let output = compose_report_json(&stat_measurements, report);
serde_json::to_writer(io::stdout(), &output).unwrap();
println!();
}
OutputFormat::JsonPretty => {
let output = compose_output_json(&stat_measurements, latency_measurement, metadata);
let output = compose_report_json(&stat_measurements, report);
serde_json::to_writer_pretty(io::stdout(), &output).unwrap();
println!();
}
Expand All @@ -118,6 +118,34 @@ pub(crate) fn log_measurements(
}
}

fn compose_report_json(
stat_measurements: &[StatMeasurement],
report: &SpeedTestReport,
) -> serde_json::Map<String, serde_json::Value> {
let mut output = compose_output_json(stat_measurements, None, report.metadata.as_ref());
output.insert(
"metadata".into(),
serde_json::to_value(&report.metadata).unwrap(),
);
output.insert(
"latency_measurement".into(),
serde_json::to_value(&report.latency).unwrap(),
);
output.insert(
"status".into(),
serde_json::to_value(report.status).unwrap(),
);
output.insert(
"stop_reason".into(),
serde_json::to_value(report.stop_reason).unwrap(),
);
output.insert(
"errors".into(),
serde_json::to_value(&report.errors).unwrap(),
);
output
}

fn compose_output_json(
stat_measurements: &[StatMeasurement],
latency_measurement: Option<&LatencyMeasurement>,
Expand Down Expand Up @@ -276,17 +304,9 @@ fn calc_stats(mbit_measurements: Vec<f64>) -> Option<(f64, f64, f64, f64, f64, f
));
}

let q1 = if length.is_multiple_of(2) {
median(&sorted_data[0..length / 2])
} else {
median(&sorted_data[0..length.div_ceil(2)])
};

let q3 = if length.is_multiple_of(2) {
median(&sorted_data[length / 2..length])
} else {
median(&sorted_data[length.div_ceil(2)..length])
};
// Median of each half, excluding the middle sample for odd lengths.
let q1 = median(&sorted_data[..length / 2]);
let q3 = median(&sorted_data[length.div_ceil(2)..]);

Some((
*sorted_data.first().unwrap(),
Expand Down Expand Up @@ -457,3 +477,24 @@ mod tests {
);
}
}

#[cfg(test)]
mod p1_regressions {
use super::*;

#[test]
fn p1_quartiles_exclude_middle_sample_consistently() {
let (_, q1, median, q3, _, _) = calc_stats(vec![1., 2., 3., 4., 5.]).unwrap();
assert_eq!((q1, median, q3), (1.5, 3., 4.5));
}

#[test]
fn p1_quartiles_are_symmetric_for_odd_and_even_samples() {
for count in 2..20 {
let values = (1..=count).map(f64::from).collect();
let (_, q1, median, q3, _, _) = calc_stats(values).unwrap();
assert_eq!(median - q1, q3 - median, "count={count}");
}
assert_eq!(calc_stats(vec![7.; 5]).unwrap(), (7., 7., 7., 7., 7., 7.));
}
}
Loading
Loading