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: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
needs: test
strategy:
matrix:
target: [x86_64-linux, x86_64-macos, aarch64-linux]
target: [x86_64-linux, x86_64-macos, aarch64-linux, x86_64-windows]
optimize: [Debug, ReleaseSafe]

steps:
Expand Down
57 changes: 37 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# fast-cli

[![Zig](https://img.shields.io/badge/Zig-0.14.0+-orange.svg)](https://ziglang.org/)
[![Zig](https://img.shields.io/badge/Zig-0.15.2-orange?logo=zig)](https://ziglang.org/)
[![CI](https://github.com/mikkelam/fast-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mikkelam/fast-cli/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A blazingly fast CLI tool for testing internet speed uses fast.com v2 api. Written in Zig for maximum performance.

⚡ **1.2 MB binary** • 🚀 **Zero runtime deps** • 📊 **Smart stability detection**
⚡ **1.2 MB binary** • 🚀 **Zero runtime deps** • 📊 **Adaptive stability-based stopping**

## Demo

Expand All @@ -17,7 +17,7 @@ A blazingly fast CLI tool for testing internet speed uses fast.com v2 api. Writt
- **Tiny binary**: Just 1.2 MB, no runtime dependencies
- **Blazing fast**: Concurrent connections with adaptive chunk sizing
- **Cross-platform**: Single binary for Linux, macOS
- **Smart stopping**: Uses Coefficient of Variation (CoV) algorithm for adaptive test duration
- **Smart stopping**: Uses a ramp + steady stability strategy and stops on stable speed or max duration

## Supported Platforms

Expand Down Expand Up @@ -50,37 +50,54 @@ zig build -Doptimize=ReleaseSafe

## Usage
```console
❯ ./fast-cli --help
Estimate connection speed using fast.com
v0.0.1

Usage: fast-cli [options]

Flags:
-u, --upload Check upload speed as well [Bool] (default: false)
-d, --duration Maximum test duration in seconds (uses Fast.com-style stability detection by default) [Int] (default: 30)
--https Use https when connecting to fast.com [Bool] (default: true)
-j, --json Output results in JSON format [Bool] (default: false)
-h, --help Shows the help for a command [Bool] (default: false)

Use "fast-cli --help" for more information.
fast-cli - Estimate connection speed using fast.com

USAGE:
fast-cli [OPTIONS]

OPTIONS:
-h, --help Display this help and exit.
--https Use HTTPS when connecting to fast.com (default)
--no-https Use HTTP instead of HTTPS
-u, --upload Check upload speed as well
-j, --json Output results in JSON format
-d, --duration <usize> Maximum test duration in seconds (effective range: 7-30, default: 30)
```

## Example Output

```console
$ fast-cli --upload
🏓 25ms | ⬇️ Download: 113.7 Mbps | ⬆️ Upload: 62.1 Mbps
🏓 25ms | ⬇️ Download: 114 Mbps | ⬆️ Upload: 62 Mbps

$ fast-cli -d 15 # Quick test with 15s max duration
🏓 22ms | ⬇️ Download: 105.0 Mbps
🏓 22ms | ⬇️ Download: 105 Mbps

$ fast-cli -j # JSON output
{"download_mbps": 131.4, "ping_ms": 20.8}
{"download_mbps": 131, "ping_ms": 20.8, "upload_mbps": null, "error": null}
```

## Stability Strategy

The speed test uses a two-phase strategy:

1. **Ramp phase**: increase active workers based on observed throughput.
2. **Steady phase**: lock worker count and estimate authoritative speed.

The test stops when either:

- speed is stable within a configured delta threshold over recent steady samples, or
- max duration is reached.

## Development

Optional: use `mise` to install and run the project toolchain.

```bash
mise install
mise exec -- zig build test
```

```bash
# Debug build
zig build
Expand Down
16 changes: 12 additions & 4 deletions src/cli/args.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ const BOLD = "\x1b[1m";
const YELLOW = "\x1b[33m";
const RESET = "\x1b[0m";

pub const duration_default_seconds: u32 = 30;
pub const duration_min_seconds: u32 = 7;
pub const duration_max_seconds: u32 = 30;

pub fn clampDurationSeconds(value: u32) u32 {
return @min(duration_max_seconds, @max(value, duration_min_seconds));
}

pub const Args = struct {
https: bool,
upload: bool,
Expand All @@ -25,15 +33,15 @@ pub const Args = struct {
}
};

const params = clap.parseParamsComptime(
const params = clap.parseParamsComptime(std.fmt.comptimePrint(
\\-h, --help Display this help and exit.
\\ --https Use HTTPS when connecting to fast.com (default)
\\ --no-https Use HTTP instead of HTTPS
\\-u, --upload Check upload speed as well
\\-j, --json Output results in JSON format
\\-d, --duration <usize> Maximum test duration in seconds (default: 30)
\\-d, --duration <usize> Maximum test duration in seconds (effective range: {d}-{d}, default: {d})
\\
);
, .{ duration_min_seconds, duration_max_seconds, duration_default_seconds }));

const parsers = .{
.usize = clap.parsers.int(u32, 10),
Expand All @@ -56,7 +64,7 @@ pub fn parse(allocator: Allocator) !Args {
.https = if (res.args.@"no-https" != 0) false else true,
.upload = res.args.upload != 0,
.json = res.args.json != 0,
.duration = res.args.duration orelse 30,
.duration = res.args.duration orelse duration_default_seconds,
.help = res.args.help != 0,
.allocator = allocator,
.clap_result = res,
Expand Down
49 changes: 23 additions & 26 deletions src/cli/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub fn run(allocator: std.mem.Allocator) !void {
var spinner = Spinner.init(allocator, .{});
defer spinner.deinit();

var fast = Fast.init(std.heap.smp_allocator, args.https);
var fast = Fast.init(allocator, args.https);
defer fast.deinit();

const urls = fast.get_urls(5) catch |err| {
Expand All @@ -53,7 +53,7 @@ pub fn run(allocator: std.mem.Allocator) !void {
}

// Measure latency
var latency_tester = HttpLatencyTester.init(std.heap.smp_allocator);
var latency_tester = HttpLatencyTester.init(allocator);
defer latency_tester.deinit();

const latency_ms = if (!args.json) blk: {
Expand All @@ -70,25 +70,26 @@ pub fn run(allocator: std.mem.Allocator) !void {

if (!args.json) {
log.info("Measuring download speed...", .{});
try spinner.start("Measuring download speed...", .{});
try spinner.start("⬇️ 0 Mbps", .{});
}

// Initialize speed tester
var speed_tester = HTTPSpeedTester.init(std.heap.smp_allocator);
var speed_tester = HTTPSpeedTester.init(allocator);
defer speed_tester.deinit();

const criteria = StabilityCriteria{
.ramp_up_duration_seconds = 4,
.max_duration_seconds = @as(u32, @intCast(@min(25, args.duration))),
.measurement_interval_ms = 750,
.sliding_window_size = 6,
.stability_threshold_cov = 0.15,
.stable_checks_required = 2,
.min_duration_seconds = Args.duration_min_seconds,
.max_duration_seconds = Args.clampDurationSeconds(args.duration),
.progress_frequency_ms = 150,
.moving_average_window_size = 5,
.stability_delta_percent = 2.0,
.min_stable_measurements = 6,
.connections_min = 1,
.max_bytes_in_flight = 78_643_200,
};

const download_result = if (args.json) blk: {
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch |err| {
try spinner.fail("Download test failed: {}", .{err});
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch {
try outputJson(null, null, null, "Download test failed");
return;
};
Expand All @@ -98,21 +99,18 @@ pub fn run(allocator: std.mem.Allocator) !void {
try spinner.fail("Download test failed: {}", .{err});
return;
};
spinner.stop();
break :blk result;
};

var upload_result: ?SpeedTestResult = null;
if (args.upload) {
if (!args.json) {
spinner.stop();
log.info("Measuring upload speed...", .{});
try spinner.start("Measuring upload speed...", .{});
try spinner.updateMessage("⬆️ 0 Mbps", .{});
}

upload_result = if (args.json) blk: {
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch |err| {
try spinner.fail("Upload test failed: {}", .{err});
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch {
try outputJson(download_result.speed.value, latency_ms, null, "Upload test failed");
return;
};
Expand All @@ -122,7 +120,6 @@ pub fn run(allocator: std.mem.Allocator) !void {
try spinner.fail("Upload test failed: {}", .{err});
return;
};
spinner.stop();
break :blk result;
};
}
Expand All @@ -131,15 +128,15 @@ pub fn run(allocator: std.mem.Allocator) !void {
if (!args.json) {
if (latency_ms) |ping| {
if (upload_result) |up| {
try spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
try spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.0} {s} | ⬆️ Upload: {d:.0} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
} else {
try spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString() });
try spinner.succeed("🏓 {d:.0}ms | ⬇️ Download: {d:.0} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString() });
}
} else {
if (upload_result) |up| {
try spinner.succeed("⬇️ Download: {d:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
try spinner.succeed("⬇️ Download: {d:.0} {s} | ⬆️ Upload: {d:.0} {s}", .{ download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
} else {
try spinner.succeed("⬇️ Download: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString() });
try spinner.succeed("⬇️ Download: {d:.0} {s}", .{ download_result.speed.value, download_result.speed.unit.toString() });
}
}
} else {
Expand All @@ -149,11 +146,11 @@ pub fn run(allocator: std.mem.Allocator) !void {
}

fn updateSpinnerText(spinner: *Spinner, measurement: SpeedMeasurement) void {
spinner.updateMessage("⬇️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
spinner.updateMessage("⬇️ {d:.0} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}

fn updateUploadSpinnerText(spinner: *Spinner, measurement: SpeedMeasurement) void {
spinner.updateMessage("⬆️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
spinner.updateMessage("⬆️ {d:.0} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
}

fn outputJson(download_mbps: ?f64, ping_ms: ?f64, upload_mbps: ?f64, error_message: ?[]const u8) !void {
Expand All @@ -166,9 +163,9 @@ fn outputJson(download_mbps: ?f64, ping_ms: ?f64, upload_mbps: ?f64, error_messa
var upload_buf: [32]u8 = undefined;
var error_buf: [256]u8 = undefined;

const download_str = if (download_mbps) |d| try std.fmt.bufPrint(&download_buf, "{d:.1}", .{d}) else "null";
const download_str = if (download_mbps) |d| try std.fmt.bufPrint(&download_buf, "{d:.0}", .{d}) else "null";
const ping_str = if (ping_ms) |p| try std.fmt.bufPrint(&ping_buf, "{d:.1}", .{p}) else "null";
const upload_str = if (upload_mbps) |u| try std.fmt.bufPrint(&upload_buf, "{d:.1}", .{u}) else "null";
const upload_str = if (upload_mbps) |u| try std.fmt.bufPrint(&upload_buf, "{d:.0}", .{u}) else "null";
const error_str = if (error_message) |e| try std.fmt.bufPrint(&error_buf, "\"{s}\"", .{e}) else "null";

try stdout.print("{{\"download_mbps\": {s}, \"ping_ms\": {s}, \"upload_mbps\": {s}, \"error\": {s}}}\n", .{ download_str, ping_str, upload_str, error_str });
Expand Down
Loading