mirror of
https://github.com/mikkelam/fast-cli.git
synced 2025-12-19 05:14:05 +00:00
Compare commits
27 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3157987bdb | ||
|
|
70bbd64eb0 | ||
|
|
9dc1218040 | ||
|
|
0066dff7c1 | ||
|
|
32cd131037 | ||
|
|
6725587bfc | ||
|
|
898b3b52c6 | ||
|
|
75a6d2fa6c | ||
|
|
af4d3971c9 | ||
|
|
55abfcb355 | ||
|
|
5ee2ee48b6 | ||
|
|
992eef432e | ||
|
|
9b3ffe4422 | ||
|
|
62bc68622a | ||
|
|
a86f9384d6 | ||
|
|
58491f0710 | ||
|
|
b3db5ee671 | ||
|
|
9aaec67e40 | ||
|
|
362648f78b | ||
|
|
d93107134f | ||
|
|
08643d487c | ||
|
|
39d2cbd9d7 | ||
|
|
1640c2a8c8 | ||
|
|
0147b5be82 | ||
|
|
325abb75cd | ||
|
|
4804eea5ad | ||
|
|
c6b72b14c1 |
15 changed files with 472 additions and 186 deletions
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -13,6 +13,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: mlugg/setup-zig@v2
|
- uses: mlugg/setup-zig@v2
|
||||||
|
with:
|
||||||
|
version: 0.15.2
|
||||||
- run: zig build test
|
- run: zig build test
|
||||||
- run: zig fmt --check src/
|
- run: zig fmt --check src/
|
||||||
|
|
||||||
|
|
@ -28,12 +30,16 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: mlugg/setup-zig@v2
|
- uses: mlugg/setup-zig@v2
|
||||||
|
with:
|
||||||
|
version: 0.15.2
|
||||||
- name: Build ${{ matrix.target }} (${{ matrix.optimize }})
|
- name: Build ${{ matrix.target }} (${{ matrix.optimize }})
|
||||||
run: |
|
run: |
|
||||||
zig build --release=${{ matrix.optimize == 'ReleaseSafe' && 'safe' || 'off' }} -Dtarget=${{ matrix.target }}
|
zig build --release=${{ matrix.optimize == 'ReleaseSafe' && 'safe' || 'off' }} -Dtarget=${{ matrix.target }}
|
||||||
|
|
||||||
- name: Verify binary
|
- name: Test help command
|
||||||
if: matrix.target == 'x86_64-linux'
|
if: matrix.target == 'x86_64-linux'
|
||||||
run: |
|
run: ./zig-out/bin/fast-cli --help
|
||||||
./zig-out/bin/fast-cli --help
|
|
||||||
file zig-out/bin/fast-cli
|
- name: Check binary type
|
||||||
|
if: matrix.target == 'x86_64-linux'
|
||||||
|
run: file zig-out/bin/fast-cli
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -19,9 +19,11 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: mlugg/setup-zig@v2
|
- uses: mlugg/setup-zig@v2
|
||||||
|
with:
|
||||||
|
version: 0.15.2
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: zig build --release=safe -Dtarget=${{ matrix.target }}
|
run: zig build -Doptimize=ReleaseFast -Dtarget=${{ matrix.target }} -Dcpu=baseline
|
||||||
|
|
||||||
- name: Prepare artifact
|
- name: Prepare artifact
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -39,6 +41,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: mlugg/setup-zig@v2
|
- uses: mlugg/setup-zig@v2
|
||||||
|
with:
|
||||||
|
version: 0.15.2
|
||||||
- run: zig build test
|
- run: zig build test
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -1,26 +1,41 @@
|
||||||
# fast-cli-zig
|
# fast-cli
|
||||||
|
|
||||||
[](https://ziglang.org/)
|
[](https://ziglang.org/)
|
||||||
[](https://github.com/mikkelam/fast-cli-zig/actions/workflows/ci.yml)
|
[](https://github.com/mikkelam/fast-cli/actions/workflows/ci.yml)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](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.
|
A blazingly fast CLI tool for testing internet speed uses fast.com v2 api. Written in Zig for maximum performance.
|
||||||
|
|
||||||
⚡ **1.3 MiB binary** • 🚀 **Zero runtime deps** • 📊 **Smart stability detection**
|
⚡ **1.2 MB binary** • 🚀 **Zero runtime deps** • 📊 **Smart stability detection**
|
||||||
|
|
||||||
## Why fast-cli-zig?
|
## Demo
|
||||||
|
|
||||||
- **Tiny binary**: Just 1.4 MiB, no runtime dependencies
|

|
||||||
|
|
||||||
|
## Why fast-cli?
|
||||||
|
|
||||||
|
- **Tiny binary**: Just 1.2 MB, no runtime dependencies
|
||||||
- **Blazing fast**: Concurrent connections with adaptive chunk sizing
|
- **Blazing fast**: Concurrent connections with adaptive chunk sizing
|
||||||
- **Cross-platform**: Single binary for Linux, macOS, Windows
|
- **Cross-platform**: Single binary for Linux, macOS
|
||||||
- **Smart stopping**: Uses Coefficient of Variation (CoV) algorithm for adaptive test duration
|
- **Smart stopping**: Uses Coefficient of Variation (CoV) algorithm for adaptive test duration
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
- **Linux**: x86_64, aarch64 (ARM64)
|
||||||
|
- **macOS**: x86_64 (Intel), aarch64 (aka Apple Silicon)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://raw.githubusercontent.com/mikkelam/fast-cli/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
### Pre-built Binaries
|
### Pre-built Binaries
|
||||||
For example, on an Apple Silicon Mac:
|
For example, on an Apple Silicon Mac:
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/mikkelam/fast-cli-zig/releases/latest/download/fast-cli-aarch64-macos.tar.gz -o fast-cli.tar.gz
|
curl -L https://github.com/mikkelam/fast-cli/releases/latest/download/fast-cli-aarch64-macos.tar.gz -o fast-cli.tar.gz
|
||||||
tar -xzf fast-cli.tar.gz
|
tar -xzf fast-cli.tar.gz
|
||||||
chmod +x fast-cli && sudo mv fast-cli /usr/local/bin/
|
chmod +x fast-cli && sudo mv fast-cli /usr/local/bin/
|
||||||
fast-cli --help
|
fast-cli --help
|
||||||
|
|
@ -28,9 +43,9 @@ fast-cli --help
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mikkelam/fast-cli-zig.git
|
git clone https://github.com/mikkelam/fast-cli.git
|
||||||
cd fast-cli-zig
|
cd fast-cli
|
||||||
zig build --release=safe
|
zig build -Doptimize=ReleaseSafe
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -74,7 +89,8 @@ zig build
|
||||||
zig build test
|
zig build test
|
||||||
|
|
||||||
# Release build
|
# Release build
|
||||||
zig build --release=safe
|
# Consider removing -Dcpu if you do not need a portable build
|
||||||
|
zig build -Doptimize=ReleaseFast -Dcpu=baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
55
build.zig
55
build.zig
|
|
@ -4,56 +4,31 @@ pub fn build(b: *std.Build) void {
|
||||||
const target = b.standardTargetOptions(.{});
|
const target = b.standardTargetOptions(.{});
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
// library tests
|
const dep_zli = b.dependency("zli", .{ .target = target });
|
||||||
const library_tests = b.addTest(.{
|
const dep_mvzr = b.dependency("mvzr", .{ .target = target, .optimize = optimize });
|
||||||
.root_source_file = b.path("src/test.zig"),
|
|
||||||
.target = target,
|
|
||||||
.optimize = optimize,
|
|
||||||
});
|
|
||||||
const run_library_tests = b.addRunArtifact(library_tests);
|
|
||||||
|
|
||||||
const test_step = b.step("test", "Run all tests");
|
|
||||||
test_step.dependOn(&run_library_tests.step);
|
|
||||||
|
|
||||||
const dep_zli = b.dependency("zli", .{
|
|
||||||
.target = target,
|
|
||||||
});
|
|
||||||
const mod_zli = dep_zli.module("zli");
|
|
||||||
|
|
||||||
const dep_mvzr = b.dependency("mvzr", .{
|
|
||||||
.target = target,
|
|
||||||
.optimize = optimize,
|
|
||||||
});
|
|
||||||
const mod_mvzr = dep_mvzr.module("mvzr");
|
|
||||||
|
|
||||||
// Create build options for version info
|
|
||||||
const build_options = b.addOptions();
|
const build_options = b.addOptions();
|
||||||
|
|
||||||
// Read version from build.zig.zon at compile time
|
|
||||||
const build_zon_content = @embedFile("build.zig.zon");
|
const build_zon_content = @embedFile("build.zig.zon");
|
||||||
const version = blk: {
|
const version = blk: {
|
||||||
// Simple parsing to extract version string
|
|
||||||
const start = std.mem.indexOf(u8, build_zon_content, ".version = \"") orelse unreachable;
|
const start = std.mem.indexOf(u8, build_zon_content, ".version = \"") orelse unreachable;
|
||||||
const version_start = start + ".version = \"".len;
|
const version_start = start + ".version = \"".len;
|
||||||
const end = std.mem.indexOfPos(u8, build_zon_content, version_start, "\"") orelse unreachable;
|
const end = std.mem.indexOfPos(u8, build_zon_content, version_start, "\"") orelse unreachable;
|
||||||
break :blk build_zon_content[version_start..end];
|
break :blk build_zon_content[version_start..end];
|
||||||
};
|
};
|
||||||
|
|
||||||
build_options.addOption([]const u8, "version", version);
|
build_options.addOption([]const u8, "version", version);
|
||||||
|
|
||||||
const exe = b.addExecutable(.{
|
const exe = b.addExecutable(.{
|
||||||
.name = "fast-cli",
|
.name = "fast-cli",
|
||||||
.root_source_file = b.path("src/main.zig"),
|
.root_module = b.createModule(.{
|
||||||
.target = target,
|
.root_source_file = b.path("src/main.zig"),
|
||||||
.optimize = optimize,
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
exe.root_module.addImport("zli", mod_zli);
|
exe.root_module.addImport("zli", dep_zli.module("zli"));
|
||||||
exe.root_module.addImport("mvzr", mod_mvzr);
|
exe.root_module.addImport("mvzr", dep_mvzr.module("mvzr"));
|
||||||
exe.root_module.addImport("build_options", build_options.createModule());
|
exe.root_module.addImport("build_options", build_options.createModule());
|
||||||
library_tests.root_module.addImport("mvzr", mod_mvzr);
|
|
||||||
|
|
||||||
// Link against the static library instead
|
|
||||||
|
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
|
|
@ -66,5 +41,15 @@ pub fn build(b: *std.Build) void {
|
||||||
const run_step = b.step("run", "Run the app");
|
const run_step = b.step("run", "Run the app");
|
||||||
run_step.dependOn(&run_cmd.step);
|
run_step.dependOn(&run_cmd.step);
|
||||||
|
|
||||||
// b.default_step.dependOn(test_step); // Disabled for cross-compilation
|
const tests = b.addTest(.{
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/test.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
tests.root_module.addImport("mvzr", dep_mvzr.module("mvzr"));
|
||||||
|
|
||||||
|
const test_step = b.step("test", "Run tests");
|
||||||
|
test_step.dependOn(&b.addRunArtifact(tests).step);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
.{
|
.{
|
||||||
.name = .fast_cli,
|
.name = .fast_cli,
|
||||||
|
|
||||||
.version = "0.1.0",
|
.version = "0.2.4",
|
||||||
|
|
||||||
.fingerprint = 0xfb5a9fbee5075971, // Changing this has security and trust implications.
|
.fingerprint = 0xfb5a9fbee5075971,
|
||||||
|
|
||||||
.minimum_zig_version = "0.14.0",
|
.minimum_zig_version = "0.15.1",
|
||||||
|
|
||||||
.dependencies = .{
|
.dependencies = .{
|
||||||
.mvzr = .{
|
|
||||||
.url = "https://github.com/mnemnion/mvzr/archive/refs/tags/v0.3.3.tar.gz",
|
|
||||||
.hash = "mvzr-0.3.2-ZSOky95lAQA00lXTN_g8JWoBuh8pw-jyzmCWAqlu1h8L",
|
|
||||||
},
|
|
||||||
.zli = .{
|
.zli = .{
|
||||||
.url = "https://github.com/xcaeser/zli/archive/v3.7.0.tar.gz",
|
.url = "https://github.com/xcaeser/zli/archive/v4.1.1.tar.gz",
|
||||||
.hash = "zli-3.7.0-LeUjpq8uAQCl8uh-ws3jdXsnbCwMZQgcZQx4TVXHLSeQ",
|
.hash = "zli-4.1.1-LeUjpljfAAAak_E3L4NPowuzPs_FUF9-jYyxuTSNSthM",
|
||||||
|
},
|
||||||
|
.mvzr = .{
|
||||||
|
.url = "https://github.com/mnemnion/mvzr/archive/refs/tags/v0.3.7.tar.gz",
|
||||||
|
.hash = "mvzr-0.3.7-ZSOky5FtAQB2VrFQPNbXHQCFJxWTMAYEK7ljYEaMR6jt",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
.paths = .{
|
.paths = .{
|
||||||
|
|
|
||||||
1
demo/fast-cli-demo.svg
Normal file
1
demo/fast-cli-demo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
209
install.sh
Executable file
209
install.sh
Executable file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Default install location
|
||||||
|
DEFAULT_INSTALL_DIR="/usr/local/bin"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
|
||||||
|
|
||||||
|
# GitHub repository
|
||||||
|
REPO="mikkelam/fast-cli"
|
||||||
|
BINARY_NAME="fast-cli"
|
||||||
|
|
||||||
|
# Print colored output
|
||||||
|
print_status() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect OS and architecture
|
||||||
|
detect_platform() {
|
||||||
|
local os arch
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux*)
|
||||||
|
os="linux"
|
||||||
|
;;
|
||||||
|
Darwin*)
|
||||||
|
os="macos"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unsupported operating system: $(uname -s)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64|amd64)
|
||||||
|
arch="x86_64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
arch="aarch64"
|
||||||
|
;;
|
||||||
|
armv7l)
|
||||||
|
arch="armv7"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unsupported architecture: $(uname -m)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "${BINARY_NAME}-${arch}-${os}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get latest release version
|
||||||
|
get_latest_version() {
|
||||||
|
local version
|
||||||
|
version=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
print_error "Failed to get latest version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download and install
|
||||||
|
install_fast_cli() {
|
||||||
|
local platform version download_url temp_dir
|
||||||
|
|
||||||
|
print_status "Detecting platform..."
|
||||||
|
platform=$(detect_platform)
|
||||||
|
print_status "Platform detected: $platform"
|
||||||
|
|
||||||
|
print_status "Getting latest version..."
|
||||||
|
version=$(get_latest_version)
|
||||||
|
print_status "Latest version: $version"
|
||||||
|
|
||||||
|
# Create download URL
|
||||||
|
download_url="https://github.com/${REPO}/releases/latest/download/${platform}.tar.gz"
|
||||||
|
|
||||||
|
# Create temporary directory
|
||||||
|
temp_dir=$(mktemp -d)
|
||||||
|
trap "rm -rf $temp_dir" EXIT
|
||||||
|
|
||||||
|
print_status "Downloading fast-cli..."
|
||||||
|
if ! curl -L --fail --silent --show-error "$download_url" -o "$temp_dir/fast-cli.tar.gz"; then
|
||||||
|
print_error "Failed to download fast-cli from $download_url"
|
||||||
|
print_error "Please check if a release exists for your platform"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "Extracting archive..."
|
||||||
|
if ! tar -xzf "$temp_dir/fast-cli.tar.gz" -C "$temp_dir"; then
|
||||||
|
print_error "Failed to extract archive"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we need sudo for installation
|
||||||
|
if [ ! -w "$INSTALL_DIR" ]; then
|
||||||
|
if [ "$INSTALL_DIR" = "$DEFAULT_INSTALL_DIR" ]; then
|
||||||
|
print_warning "Installing to $INSTALL_DIR requires sudo privileges"
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
print_error "No write permission to $INSTALL_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "Installing to $INSTALL_DIR..."
|
||||||
|
$SUDO mkdir -p "$INSTALL_DIR"
|
||||||
|
$SUDO cp "$temp_dir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
|
||||||
|
$SUDO chmod +x "$INSTALL_DIR/$BINARY_NAME"
|
||||||
|
|
||||||
|
print_status "Installation complete!"
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}✓${NC} fast-cli installed to $INSTALL_DIR/$BINARY_NAME"
|
||||||
|
echo
|
||||||
|
echo "Try it out:"
|
||||||
|
echo " $BINARY_NAME --help"
|
||||||
|
echo " $BINARY_NAME --json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show usage
|
||||||
|
show_usage() {
|
||||||
|
cat << EOF
|
||||||
|
Fast-CLI Installer
|
||||||
|
|
||||||
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--prefix DIR Install to custom directory (default: $DEFAULT_INSTALL_DIR)
|
||||||
|
--help Show this help message
|
||||||
|
|
||||||
|
Environment Variables:
|
||||||
|
INSTALL_DIR Custom installation directory
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Install to default location
|
||||||
|
$0
|
||||||
|
|
||||||
|
# Install to custom directory
|
||||||
|
$0 --prefix /opt/bin
|
||||||
|
INSTALL_DIR=/opt/bin $0
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--prefix)
|
||||||
|
INSTALL_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
show_usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
print_error "Unknown option: $1"
|
||||||
|
show_usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
check_dependencies() {
|
||||||
|
local missing_deps=()
|
||||||
|
|
||||||
|
for cmd in curl tar; do
|
||||||
|
if ! command -v "$cmd" > /dev/null 2>&1; then
|
||||||
|
missing_deps+=("$cmd")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||||
|
print_error "Missing required dependencies: ${missing_deps[*]}"
|
||||||
|
print_error "Please install them and try again"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
main() {
|
||||||
|
echo "Fast-CLI Installer"
|
||||||
|
echo "=================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
check_dependencies
|
||||||
|
install_fast_cli
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
104
src/cli/root.zig
104
src/cli/root.zig
|
|
@ -1,6 +1,9 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zli = @import("zli");
|
const zli = @import("zli");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
|
const Writer = std.Io.Writer;
|
||||||
|
|
||||||
|
const log = std.log.scoped(.cli);
|
||||||
|
|
||||||
const Fast = @import("../lib/fast.zig").Fast;
|
const Fast = @import("../lib/fast.zig").Fast;
|
||||||
const HTTPSpeedTester = @import("../lib/http_speed_tester_v2.zig").HTTPSpeedTester;
|
const HTTPSpeedTester = @import("../lib/http_speed_tester_v2.zig").HTTPSpeedTester;
|
||||||
|
|
@ -11,17 +14,6 @@ const BandwidthMeter = @import("../lib/bandwidth.zig");
|
||||||
const SpeedMeasurement = @import("../lib/bandwidth.zig").SpeedMeasurement;
|
const SpeedMeasurement = @import("../lib/bandwidth.zig").SpeedMeasurement;
|
||||||
const progress = @import("../lib/progress.zig");
|
const progress = @import("../lib/progress.zig");
|
||||||
const HttpLatencyTester = @import("../lib/http_latency_tester.zig").HttpLatencyTester;
|
const HttpLatencyTester = @import("../lib/http_latency_tester.zig").HttpLatencyTester;
|
||||||
const log = std.log.scoped(.cli);
|
|
||||||
|
|
||||||
/// Update spinner text with current speed measurement
|
|
||||||
fn updateSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
|
|
||||||
spinner.updateText("⬇️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update spinner text with current upload speed measurement
|
|
||||||
fn updateUploadSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
|
|
||||||
spinner.updateText("⬆️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const https_flag = zli.Flag{
|
const https_flag = zli.Flag{
|
||||||
.name = "https",
|
.name = "https",
|
||||||
|
|
@ -54,8 +46,8 @@ const max_duration_flag = zli.Flag{
|
||||||
.default_value = .{ .Int = 30 },
|
.default_value = .{ .Int = 30 },
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn build(allocator: std.mem.Allocator) !*zli.Command {
|
pub fn build(writer: *Writer, allocator: std.mem.Allocator) !*zli.Command {
|
||||||
const root = try zli.Command.init(allocator, .{
|
const root = try zli.Command.init(writer, allocator, .{
|
||||||
.name = "fast-cli",
|
.name = "fast-cli",
|
||||||
.description = "Estimate connection speed using fast.com",
|
.description = "Estimate connection speed using fast.com",
|
||||||
.version = null,
|
.version = null,
|
||||||
|
|
@ -75,35 +67,41 @@ fn run(ctx: zli.CommandContext) !void {
|
||||||
const json_output = ctx.flag("json", bool);
|
const json_output = ctx.flag("json", bool);
|
||||||
const max_duration = ctx.flag("duration", i64);
|
const max_duration = ctx.flag("duration", i64);
|
||||||
|
|
||||||
|
const spinner = ctx.spinner;
|
||||||
|
|
||||||
log.info("Config: https={}, upload={}, json={}, max_duration={}s", .{
|
log.info("Config: https={}, upload={}, json={}, max_duration={}s", .{
|
||||||
use_https, check_upload, json_output, max_duration,
|
use_https, check_upload, json_output, max_duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
var fast = Fast.init(std.heap.page_allocator, use_https);
|
var fast = Fast.init(std.heap.smp_allocator, use_https);
|
||||||
defer fast.deinit();
|
defer fast.deinit();
|
||||||
|
|
||||||
const urls = fast.get_urls(5) catch |err| {
|
const urls = fast.get_urls(5) catch |err| {
|
||||||
if (!json_output) {
|
if (!json_output) {
|
||||||
try ctx.spinner.fail("Failed to get URLs: {}", .{err});
|
try spinner.fail("Failed to get URLs: {}", .{err});
|
||||||
} else {
|
} else {
|
||||||
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
|
const error_msg = switch (err) {
|
||||||
|
error.ConnectionTimeout => "Failed to contact fast.com servers",
|
||||||
|
else => "Failed to get URLs",
|
||||||
|
};
|
||||||
|
try outputJson(ctx.writer, null, null, null, error_msg);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
log.info("Got {} URLs", .{urls.len});
|
log.info("Got {} URLs\n", .{urls.len});
|
||||||
for (urls) |url| {
|
for (urls) |url| {
|
||||||
log.debug("URL: {s}", .{url});
|
log.info("URL: {s}\n", .{url});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measure latency first
|
// Measure latency first
|
||||||
var latency_tester = HttpLatencyTester.init(std.heap.page_allocator);
|
var latency_tester = HttpLatencyTester.init(std.heap.smp_allocator);
|
||||||
defer latency_tester.deinit();
|
defer latency_tester.deinit();
|
||||||
|
|
||||||
const latency_ms = if (!json_output) blk: {
|
const latency_ms = if (!json_output) blk: {
|
||||||
try ctx.spinner.start(.{}, "Measuring latency...", .{});
|
try spinner.start("Measuring latency...", .{});
|
||||||
const result = latency_tester.measureLatency(urls) catch |err| {
|
const result = latency_tester.measureLatency(urls) catch |err| {
|
||||||
log.err("Latency test failed: {}", .{err});
|
try spinner.fail("Latency test failed: {}", .{err});
|
||||||
break :blk null;
|
break :blk null;
|
||||||
};
|
};
|
||||||
break :blk result;
|
break :blk result;
|
||||||
|
|
@ -112,11 +110,11 @@ fn run(ctx: zli.CommandContext) !void {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!json_output) {
|
if (!json_output) {
|
||||||
try ctx.spinner.start(.{}, "Measuring download speed...", .{});
|
log.info("Measuring download speed...", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize speed tester
|
// Initialize speed tester
|
||||||
var speed_tester = HTTPSpeedTester.init(std.heap.page_allocator);
|
var speed_tester = HTTPSpeedTester.init(std.heap.smp_allocator);
|
||||||
defer speed_tester.deinit();
|
defer speed_tester.deinit();
|
||||||
|
|
||||||
// Use Fast.com-style stability detection by default
|
// Use Fast.com-style stability detection by default
|
||||||
|
|
@ -132,15 +130,15 @@ fn run(ctx: zli.CommandContext) !void {
|
||||||
const download_result = if (json_output) blk: {
|
const download_result = if (json_output) blk: {
|
||||||
// JSON mode: clean output only
|
// JSON mode: clean output only
|
||||||
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch |err| {
|
break :blk speed_tester.measure_download_speed_stability(urls, criteria) catch |err| {
|
||||||
log.err("Download test failed: {}", .{err});
|
try spinner.fail("Download test failed: {}", .{err});
|
||||||
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
|
try outputJson(ctx.writer, null, null, null, "Download test failed");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
} else blk: {
|
} else blk: {
|
||||||
// Interactive mode with spinner updates
|
// Interactive mode with spinner updates
|
||||||
const progressCallback = progress.createCallback(ctx.spinner, updateSpinnerText);
|
const progressCallback = progress.createCallback(spinner, updateSpinnerText);
|
||||||
break :blk speed_tester.measureDownloadSpeedWithStabilityProgress(urls, criteria, progressCallback) catch |err| {
|
break :blk speed_tester.measureDownloadSpeedWithStabilityProgress(urls, criteria, progressCallback) catch |err| {
|
||||||
try ctx.spinner.fail("Download test failed: {}", .{err});
|
try spinner.fail("Download test failed: {}", .{err});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -148,21 +146,21 @@ fn run(ctx: zli.CommandContext) !void {
|
||||||
var upload_result: ?SpeedTestResult = null;
|
var upload_result: ?SpeedTestResult = null;
|
||||||
if (check_upload) {
|
if (check_upload) {
|
||||||
if (!json_output) {
|
if (!json_output) {
|
||||||
try ctx.spinner.start(.{}, "Measuring upload speed...", .{});
|
log.info("Measuring upload speed...", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
upload_result = if (json_output) blk: {
|
upload_result = if (json_output) blk: {
|
||||||
// JSON mode: clean output only
|
// JSON mode: clean output only
|
||||||
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch |err| {
|
break :blk speed_tester.measure_upload_speed_stability(urls, criteria) catch |err| {
|
||||||
log.err("Upload test failed: {}", .{err});
|
try spinner.fail("Upload test failed: {}", .{err});
|
||||||
std.debug.print("{{\"error\": \"{}\"}}\n", .{err});
|
try outputJson(ctx.writer, download_result.speed.value, latency_ms, null, "Upload test failed");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
} else blk: {
|
} else blk: {
|
||||||
// Interactive mode with spinner updates
|
// Interactive mode with spinner updates
|
||||||
const uploadProgressCallback = progress.createCallback(ctx.spinner, updateUploadSpinnerText);
|
const uploadProgressCallback = progress.createCallback(spinner, updateUploadSpinnerText);
|
||||||
break :blk speed_tester.measureUploadSpeedWithStabilityProgress(urls, criteria, uploadProgressCallback) catch |err| {
|
break :blk speed_tester.measureUploadSpeedWithStabilityProgress(urls, criteria, uploadProgressCallback) catch |err| {
|
||||||
try ctx.spinner.fail("Upload test failed: {}", .{err});
|
try spinner.fail("Upload test failed: {}", .{err});
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -172,25 +170,43 @@ fn run(ctx: zli.CommandContext) !void {
|
||||||
if (!json_output) {
|
if (!json_output) {
|
||||||
if (latency_ms) |ping| {
|
if (latency_ms) |ping| {
|
||||||
if (upload_result) |up| {
|
if (upload_result) |up| {
|
||||||
try ctx.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:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
|
||||||
} else {
|
} else {
|
||||||
try ctx.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:.1} {s}", .{ ping, download_result.speed.value, download_result.speed.unit.toString() });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (upload_result) |up| {
|
if (upload_result) |up| {
|
||||||
try ctx.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:.1} {s} | ⬆️ Upload: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString(), up.speed.value, up.speed.unit.toString() });
|
||||||
} else {
|
} else {
|
||||||
try ctx.spinner.succeed("⬇️ Download: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString() });
|
try spinner.succeed("⬇️ Download: {d:.1} {s}", .{ download_result.speed.value, download_result.speed.unit.toString() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
std.debug.print("{{\"download_mbps\": {d:.1}", .{download_result.speed.value});
|
const upload_speed = if (upload_result) |up| up.speed.value else null;
|
||||||
if (latency_ms) |ping| {
|
try outputJson(ctx.writer, download_result.speed.value, latency_ms, upload_speed, null);
|
||||||
std.debug.print(", \"ping_ms\": {d:.1}", .{ping});
|
|
||||||
}
|
|
||||||
if (upload_result) |up| {
|
|
||||||
std.debug.print(", \"upload_mbps\": {d:.1}", .{up.speed.value});
|
|
||||||
}
|
|
||||||
std.debug.print("}}\n", .{});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update spinner text with current speed measurement
|
||||||
|
fn updateSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
|
||||||
|
spinner.updateMessage("⬇️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update spinner text with current upload speed measurement
|
||||||
|
fn updateUploadSpinnerText(spinner: anytype, measurement: SpeedMeasurement) void {
|
||||||
|
spinner.updateMessage("⬆️ {d:.1} {s}", .{ measurement.value, measurement.unit.toString() }) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outputJson(writer: *Writer, download_mbps: ?f64, ping_ms: ?f64, upload_mbps: ?f64, error_message: ?[]const u8) !void {
|
||||||
|
var download_buf: [32]u8 = undefined;
|
||||||
|
var ping_buf: [32]u8 = undefined;
|
||||||
|
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 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 error_str = if (error_message) |e| try std.fmt.bufPrint(&error_buf, "\"{s}\"", .{e}) else "null";
|
||||||
|
|
||||||
|
try writer.print("{{\"download_mbps\": {s}, \"ping_ms\": {s}, \"upload_mbps\": {s}, \"error\": {s}}}\n", .{ download_str, ping_str, upload_str, error_str });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ test "BandwidthMeter bandwidth calculation" {
|
||||||
meter.update_total(1000); // 1000 bytes
|
meter.update_total(1000); // 1000 bytes
|
||||||
|
|
||||||
// Sleep briefly to ensure time passes
|
// Sleep briefly to ensure time passes
|
||||||
std.time.sleep(std.time.ns_per_ms * 10); // 10ms
|
std.Thread.sleep(std.time.ns_per_ms * 10); // 10ms
|
||||||
|
|
||||||
const bw = meter.bandwidth();
|
const bw = meter.bandwidth();
|
||||||
try testing.expect(bw > 0);
|
try testing.expect(bw > 0);
|
||||||
|
|
@ -127,7 +127,7 @@ test "BandwidthMeter unit conversion" {
|
||||||
// Test different speed ranges
|
// Test different speed ranges
|
||||||
meter._bytes_transferred = 1000;
|
meter._bytes_transferred = 1000;
|
||||||
meter._timer = try std.time.Timer.start();
|
meter._timer = try std.time.Timer.start();
|
||||||
std.time.sleep(std.time.ns_per_s); // 1 second
|
std.Thread.sleep(std.time.ns_per_s); // 1 second
|
||||||
|
|
||||||
const measurement = meter.bandwidthWithUnits();
|
const measurement = meter.bandwidthWithUnits();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,28 @@ const testing = std.testing;
|
||||||
const log = std.log.scoped(.fast_api);
|
const log = std.log.scoped(.fast_api);
|
||||||
|
|
||||||
const mvzr = @import("mvzr");
|
const mvzr = @import("mvzr");
|
||||||
|
|
||||||
const FastError = error{
|
const FastError = error{
|
||||||
HttpRequestFailed,
|
HttpRequestFailed,
|
||||||
ScriptNotFound,
|
ScriptNotFound,
|
||||||
TokenNotFound,
|
TokenNotFound,
|
||||||
JsonParseError,
|
JsonParseError,
|
||||||
|
ConnectionTimeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Location = struct {
|
const Location = struct { city: []const u8, country: []const u8 };
|
||||||
city: []const u8,
|
|
||||||
country: []const u8,
|
|
||||||
};
|
|
||||||
|
|
||||||
const Client = struct {
|
const Client = struct {
|
||||||
ip: []const u8,
|
ip: []const u8,
|
||||||
asn: []const u8,
|
asn: ?[]const u8 = null,
|
||||||
isp: []const u8,
|
isp: ?[]const u8 = null,
|
||||||
location: Location,
|
location: ?Location = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const Target = struct {
|
const Target = struct {
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
url: []const u8,
|
url: []const u8,
|
||||||
location: Location,
|
location: ?Location = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FastResponse = struct {
|
const FastResponse = struct {
|
||||||
|
|
@ -72,7 +71,7 @@ pub const Fast = struct {
|
||||||
|
|
||||||
var result = try Fast.parse_response_urls(json_data.items, allocator);
|
var result = try Fast.parse_response_urls(json_data.items, allocator);
|
||||||
|
|
||||||
return result.toOwnedSlice();
|
return result.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanitizes JSON data by replacing invalid UTF-8 bytes that cause parseFromSlice to fail.
|
/// Sanitizes JSON data by replacing invalid UTF-8 bytes that cause parseFromSlice to fail.
|
||||||
|
|
@ -103,7 +102,7 @@ pub const Fast = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_response_urls(json_data: []const u8, result_allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
|
fn parse_response_urls(json_data: []const u8, result_allocator: std.mem.Allocator) !std.ArrayList([]const u8) {
|
||||||
var result = std.ArrayList([]const u8).init(result_allocator);
|
var result = std.ArrayList([]const u8).empty;
|
||||||
|
|
||||||
const sanitized_json = try sanitize_json(json_data, result_allocator);
|
const sanitized_json = try sanitize_json(json_data, result_allocator);
|
||||||
defer result_allocator.free(sanitized_json);
|
defer result_allocator.free(sanitized_json);
|
||||||
|
|
@ -120,7 +119,7 @@ pub const Fast = struct {
|
||||||
|
|
||||||
for (response.targets) |target| {
|
for (response.targets) |target| {
|
||||||
const url_copy = try result_allocator.dupe(u8, target.url);
|
const url_copy = try result_allocator.dupe(u8, target.url);
|
||||||
try result.append(url_copy);
|
try result.append(result_allocator, url_copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -156,14 +155,39 @@ pub const Fast = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_page(self: *Fast, allocator: std.mem.Allocator, url: []const u8) !std.ArrayList(u8) {
|
fn get_page(self: *Fast, allocator: std.mem.Allocator, url: []const u8) !std.ArrayList(u8) {
|
||||||
_ = allocator;
|
var response_body = std.Io.Writer.Allocating.init(allocator);
|
||||||
var response_body = std.ArrayList(u8).init(self.arena.allocator());
|
|
||||||
|
|
||||||
const response: http.Client.FetchResult = try self.client.fetch(.{
|
const response: http.Client.FetchResult = self.client.fetch(.{
|
||||||
.method = .GET,
|
.method = .GET,
|
||||||
.location = .{ .url = url },
|
.location = .{ .url = url },
|
||||||
.response_storage = .{ .dynamic = &response_body },
|
.response_writer = &response_body.writer,
|
||||||
});
|
// .response_storage = .{ .dynamic = &response_body },
|
||||||
|
}) catch |err| switch (err) {
|
||||||
|
error.NetworkUnreachable, error.ConnectionRefused => {
|
||||||
|
log.err("Failed to reach fast.com servers (network/connection error) for URL: {s}", .{url});
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
error.UnknownHostName, error.NameServerFailure, error.TemporaryNameServerFailure, error.HostLacksNetworkAddresses => {
|
||||||
|
log.err("Failed to resolve fast.com hostname (DNS/internet connection issue) for URL: {s}", .{url});
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
error.ConnectionTimedOut, error.ConnectionResetByPeer => {
|
||||||
|
log.err("Connection to fast.com servers timed out or was reset for URL: {s}", .{url});
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
error.TlsInitializationFailed => {
|
||||||
|
log.err("Failed to establish secure connection to fast.com servers for URL: {s}", .{url});
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
error.UnexpectedConnectFailure => {
|
||||||
|
log.err("Unexpected connection failure to fast.com servers for URL: {s}", .{url});
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
log.err("Network error: {} for URL: {s}", .{ err, url });
|
||||||
|
return error.ConnectionTimeout;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
log.debug("HTTP response status: {} for URL: {s}", .{ response.status, url });
|
log.debug("HTTP response status: {} for URL: {s}", .{ response.status, url });
|
||||||
|
|
||||||
|
|
@ -171,7 +195,7 @@ pub const Fast = struct {
|
||||||
log.err("HTTP request failed with status code {}", .{response.status});
|
log.err("HTTP request failed with status code {}", .{response.status});
|
||||||
return error.HttpRequestFailed;
|
return error.HttpRequestFailed;
|
||||||
}
|
}
|
||||||
return response_body;
|
return response_body.toArrayList();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -181,12 +205,12 @@ test "parse_response_urls_v2" {
|
||||||
;
|
;
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
const urls = try Fast.parse_response_urls(response, allocator);
|
var urls = try Fast.parse_response_urls(response, allocator);
|
||||||
defer {
|
defer {
|
||||||
for (urls.items) |url| {
|
for (urls.items) |url| {
|
||||||
allocator.free(url);
|
allocator.free(url);
|
||||||
}
|
}
|
||||||
urls.deinit();
|
urls.deinit(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
try testing.expect(urls.items.len == 2);
|
try testing.expect(urls.items.len == 2);
|
||||||
|
|
@ -244,3 +268,39 @@ test "extract_token" {
|
||||||
defer allocator.free(token);
|
defer allocator.free(token);
|
||||||
try testing.expect(std.mem.eql(u8, token, "abcdef123456"));
|
try testing.expect(std.mem.eql(u8, token, "abcdef123456"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parse_response_without_isp" {
|
||||||
|
const response =
|
||||||
|
\\{"client":{"ip":"87.52.107.67","asn":"3292","location":{"city":"Test","country":"DK"}},"targets":[{"name":"https://example.com/0","url":"https://example.com/0","location":{"city":"Test","country":"DK"}}]}
|
||||||
|
;
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
var urls = try Fast.parse_response_urls(response, allocator);
|
||||||
|
defer {
|
||||||
|
for (urls.items) |url| {
|
||||||
|
allocator.free(url);
|
||||||
|
}
|
||||||
|
urls.deinit(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
try testing.expect(urls.items.len == 1);
|
||||||
|
try testing.expect(std.mem.eql(u8, urls.items[0], "https://example.com/0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse_response_minimal_client" {
|
||||||
|
const response =
|
||||||
|
\\{"client":{"ip":"87.52.107.67"},"targets":[{"name":"https://example.com/0","url":"https://example.com/0"}]}
|
||||||
|
;
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
var urls = try Fast.parse_response_urls(response, allocator);
|
||||||
|
defer {
|
||||||
|
for (urls.items) |url| {
|
||||||
|
allocator.free(url);
|
||||||
|
}
|
||||||
|
urls.deinit(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
try testing.expect(urls.items.len == 1);
|
||||||
|
try testing.expect(std.mem.eql(u8, urls.items[0], "https://example.com/0"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const http = std.http;
|
const http = std.http;
|
||||||
|
const log = std.log.scoped(.cli);
|
||||||
|
|
||||||
pub const HttpLatencyTester = struct {
|
pub const HttpLatencyTester = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -18,16 +19,22 @@ pub const HttpLatencyTester = struct {
|
||||||
|
|
||||||
/// Measure latency to multiple URLs using HEAD requests
|
/// Measure latency to multiple URLs using HEAD requests
|
||||||
/// Returns median latency in milliseconds, or null if all requests failed
|
/// Returns median latency in milliseconds, or null if all requests failed
|
||||||
|
/// Zig's http client seems to be ~20ms slower than curl.
|
||||||
|
/// Let's not worry about that misreporting for now
|
||||||
pub fn measureLatency(self: *Self, urls: []const []const u8) !?f64 {
|
pub fn measureLatency(self: *Self, urls: []const []const u8) !?f64 {
|
||||||
if (urls.len == 0) return null;
|
if (urls.len == 0) return null;
|
||||||
|
|
||||||
var latencies = std.ArrayList(f64).init(self.allocator);
|
var latencies: std.ArrayList(f64) = .{};
|
||||||
defer latencies.deinit();
|
defer latencies.deinit(self.allocator);
|
||||||
|
|
||||||
|
// HTTP client for all requests
|
||||||
|
var client = http.Client{ .allocator = self.allocator };
|
||||||
|
defer client.deinit();
|
||||||
|
|
||||||
// Test each URL
|
// Test each URL
|
||||||
for (urls) |url| {
|
for (urls) |url| {
|
||||||
if (self.measureSingleUrl(url)) |latency_ms| {
|
if (self.measureSingleUrl(url, &client)) |latency_ms| {
|
||||||
try latencies.append(latency_ms);
|
try latencies.append(self.allocator, latency_ms);
|
||||||
} else |_| {
|
} else |_| {
|
||||||
// Ignore errors, continue with other URLs
|
// Ignore errors, continue with other URLs
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -36,50 +43,26 @@ pub const HttpLatencyTester = struct {
|
||||||
|
|
||||||
if (latencies.items.len == 0) return null;
|
if (latencies.items.len == 0) return null;
|
||||||
|
|
||||||
|
log.info("Latencies: {any}", .{latencies.items});
|
||||||
|
|
||||||
// Return median latency
|
// Return median latency
|
||||||
return self.calculateMedian(latencies.items);
|
return self.calculateMedian(latencies.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure latency to a single URL using connection reuse method
|
/// Measure latency to a single URL using HEAD request
|
||||||
/// First request establishes HTTPS connection, second request measures pure RTT
|
fn measureSingleUrl(self: *Self, url: []const u8, client: *http.Client) !f64 {
|
||||||
fn measureSingleUrl(self: *Self, url: []const u8) !f64 {
|
_ = self;
|
||||||
var client = http.Client{ .allocator = self.allocator };
|
|
||||||
defer client.deinit();
|
|
||||||
|
|
||||||
// Parse URL
|
// Parse URL
|
||||||
const uri = try std.Uri.parse(url);
|
const uri = try std.Uri.parse(url);
|
||||||
|
|
||||||
// First request: Establish HTTPS connection (ignore timing)
|
// Measure request/response timing
|
||||||
{
|
|
||||||
const server_header_buffer = try self.allocator.alloc(u8, 4096);
|
|
||||||
defer self.allocator.free(server_header_buffer);
|
|
||||||
|
|
||||||
var req = try client.open(.HEAD, uri, .{
|
|
||||||
.server_header_buffer = server_header_buffer,
|
|
||||||
});
|
|
||||||
defer req.deinit();
|
|
||||||
|
|
||||||
try req.send();
|
|
||||||
try req.finish();
|
|
||||||
try req.wait();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second request: Reuse connection and measure pure HTTP RTT
|
|
||||||
const start_time = std.time.nanoTimestamp();
|
const start_time = std.time.nanoTimestamp();
|
||||||
|
|
||||||
{
|
_ = try client.fetch(.{
|
||||||
const server_header_buffer = try self.allocator.alloc(u8, 4096);
|
.method = .HEAD,
|
||||||
defer self.allocator.free(server_header_buffer);
|
.location = .{ .uri = uri },
|
||||||
|
});
|
||||||
var req = try client.open(.HEAD, uri, .{
|
|
||||||
.server_header_buffer = server_header_buffer,
|
|
||||||
});
|
|
||||||
defer req.deinit();
|
|
||||||
|
|
||||||
try req.send();
|
|
||||||
try req.finish();
|
|
||||||
try req.wait();
|
|
||||||
}
|
|
||||||
|
|
||||||
const end_time = std.time.nanoTimestamp();
|
const end_time = std.time.nanoTimestamp();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ pub const HTTPSpeedTester = struct {
|
||||||
|
|
||||||
// Main measurement loop
|
// Main measurement loop
|
||||||
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
||||||
std.time.sleep(strategy.getSleepInterval());
|
std.Thread.sleep(strategy.getSleepInterval());
|
||||||
|
|
||||||
if (has_progress) {
|
if (has_progress) {
|
||||||
const current_bytes = worker_manager.getCurrentDownloadBytes(workers);
|
const current_bytes = worker_manager.getCurrentDownloadBytes(workers);
|
||||||
|
|
@ -221,7 +221,7 @@ pub const HTTPSpeedTester = struct {
|
||||||
|
|
||||||
// Main measurement loop
|
// Main measurement loop
|
||||||
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
||||||
std.time.sleep(strategy.getSleepInterval());
|
std.Thread.sleep(strategy.getSleepInterval());
|
||||||
|
|
||||||
if (has_progress) {
|
if (has_progress) {
|
||||||
const current_bytes = worker_manager.getCurrentUploadBytes(workers);
|
const current_bytes = worker_manager.getCurrentUploadBytes(workers);
|
||||||
|
|
@ -285,7 +285,7 @@ pub const HTTPSpeedTester = struct {
|
||||||
|
|
||||||
// Main measurement loop
|
// Main measurement loop
|
||||||
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
||||||
std.time.sleep(strategy.getSleepInterval());
|
std.Thread.sleep(strategy.getSleepInterval());
|
||||||
|
|
||||||
const current_bytes = worker_manager.getCurrentDownloadBytes(workers);
|
const current_bytes = worker_manager.getCurrentDownloadBytes(workers);
|
||||||
|
|
||||||
|
|
@ -359,7 +359,7 @@ pub const HTTPSpeedTester = struct {
|
||||||
|
|
||||||
// Main measurement loop
|
// Main measurement loop
|
||||||
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
while (strategy.shouldContinue(timer.timer_interface().read())) {
|
||||||
std.time.sleep(strategy.getSleepInterval());
|
std.Thread.sleep(strategy.getSleepInterval());
|
||||||
|
|
||||||
const current_bytes = worker_manager.getCurrentUploadBytes(workers);
|
const current_bytes = worker_manager.getCurrentUploadBytes(workers);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ pub const StabilityStrategy = struct {
|
||||||
last_sample_time: u64 = 0,
|
last_sample_time: u64 = 0,
|
||||||
last_total_bytes: u64 = 0,
|
last_total_bytes: u64 = 0,
|
||||||
consecutive_stable_checks: u32 = 0,
|
consecutive_stable_checks: u32 = 0,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
|
||||||
pub fn init(allocator: std.mem.Allocator, criteria: StabilityCriteria) StabilityStrategy {
|
pub fn init(allocator: std.mem.Allocator, criteria: StabilityCriteria) StabilityStrategy {
|
||||||
return StabilityStrategy{
|
return StabilityStrategy{
|
||||||
|
|
@ -38,12 +39,13 @@ pub const StabilityStrategy = struct {
|
||||||
.ramp_up_duration_ns = @as(u64, criteria.ramp_up_duration_seconds) * std.time.ns_per_s,
|
.ramp_up_duration_ns = @as(u64, criteria.ramp_up_duration_seconds) * std.time.ns_per_s,
|
||||||
.max_duration_ns = @as(u64, criteria.max_duration_seconds) * std.time.ns_per_s,
|
.max_duration_ns = @as(u64, criteria.max_duration_seconds) * std.time.ns_per_s,
|
||||||
.measurement_interval_ns = criteria.measurement_interval_ms * std.time.ns_per_ms,
|
.measurement_interval_ns = criteria.measurement_interval_ms * std.time.ns_per_ms,
|
||||||
.speed_measurements = std.ArrayList(f64).init(allocator),
|
.speed_measurements = std.ArrayList(f64).empty,
|
||||||
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *StabilityStrategy) void {
|
pub fn deinit(self: *StabilityStrategy) void {
|
||||||
self.speed_measurements.deinit();
|
self.speed_measurements.deinit(self.allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shouldContinue(self: StabilityStrategy, current_time: u64) bool {
|
pub fn shouldContinue(self: StabilityStrategy, current_time: u64) bool {
|
||||||
|
|
@ -69,7 +71,7 @@ pub const StabilityStrategy = struct {
|
||||||
|
|
||||||
// Phase 1: Ramp-up - collect measurements but don't check stability
|
// Phase 1: Ramp-up - collect measurements but don't check stability
|
||||||
if (current_time < self.ramp_up_duration_ns) {
|
if (current_time < self.ramp_up_duration_ns) {
|
||||||
try self.speed_measurements.append(interval_speed);
|
try self.speed_measurements.append(self.allocator, interval_speed);
|
||||||
|
|
||||||
// Keep sliding window size
|
// Keep sliding window size
|
||||||
if (self.speed_measurements.items.len > self.criteria.sliding_window_size) {
|
if (self.speed_measurements.items.len > self.criteria.sliding_window_size) {
|
||||||
|
|
@ -77,7 +79,7 @@ pub const StabilityStrategy = struct {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Phase 2: Stabilization - check CoV for stability
|
// Phase 2: Stabilization - check CoV for stability
|
||||||
try self.speed_measurements.append(interval_speed);
|
try self.speed_measurements.append(self.allocator, interval_speed);
|
||||||
|
|
||||||
// Maintain sliding window
|
// Maintain sliding window
|
||||||
if (self.speed_measurements.items.len > self.criteria.sliding_window_size) {
|
if (self.speed_measurements.items.len > self.criteria.sliding_window_size) {
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ pub const DownloadWorker = struct {
|
||||||
_ = self.error_count.fetchAdd(1, .monotonic);
|
_ = self.error_count.fetchAdd(1, .monotonic);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
std.time.sleep(std.time.ns_per_ms * 100);
|
std.Thread.sleep(std.time.ns_per_ms * 100);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
defer response.deinit();
|
defer response.deinit();
|
||||||
|
|
@ -183,7 +183,7 @@ pub const DownloadWorker = struct {
|
||||||
// Accept both 200 (full content) and 206 (partial content)
|
// Accept both 200 (full content) and 206 (partial content)
|
||||||
if (response.status != .ok and response.status != .partial_content) {
|
if (response.status != .ok and response.status != .partial_content) {
|
||||||
print("Worker {} HTTP error: {}\n", .{ self.config.worker_id, response.status });
|
print("Worker {} HTTP error: {}\n", .{ self.config.worker_id, response.status });
|
||||||
std.time.sleep(std.time.ns_per_ms * 100);
|
std.Thread.sleep(std.time.ns_per_ms * 100);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ pub const DownloadWorker = struct {
|
||||||
|
|
||||||
// Small delay between requests
|
// Small delay between requests
|
||||||
if (self.config.delay_between_requests_ms > 0) {
|
if (self.config.delay_between_requests_ms > 0) {
|
||||||
std.time.sleep(std.time.ns_per_ms * self.config.delay_between_requests_ms);
|
std.Thread.sleep(std.time.ns_per_ms * self.config.delay_between_requests_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +318,7 @@ pub const UploadWorker = struct {
|
||||||
_ = self.error_count.fetchAdd(1, .monotonic);
|
_ = self.error_count.fetchAdd(1, .monotonic);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
std.time.sleep(std.time.ns_per_ms * 100);
|
std.Thread.sleep(std.time.ns_per_ms * 100);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
defer response.deinit();
|
defer response.deinit();
|
||||||
|
|
@ -331,7 +331,7 @@ pub const UploadWorker = struct {
|
||||||
|
|
||||||
if (response.status != .ok) {
|
if (response.status != .ok) {
|
||||||
print("Upload worker {} HTTP error: {}\n", .{ self.config.worker_id, response.status });
|
print("Upload worker {} HTTP error: {}\n", .{ self.config.worker_id, response.status });
|
||||||
std.time.sleep(std.time.ns_per_ms * 100);
|
std.Thread.sleep(std.time.ns_per_ms * 100);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,15 +404,14 @@ pub const RealHttpClient = struct {
|
||||||
fn fetch(ptr: *anyopaque, request: FetchRequest) !FetchResponse {
|
fn fetch(ptr: *anyopaque, request: FetchRequest) !FetchResponse {
|
||||||
const self: *Self = @ptrCast(@alignCast(ptr));
|
const self: *Self = @ptrCast(@alignCast(ptr));
|
||||||
|
|
||||||
var response_body = std.ArrayList(u8).init(self.allocator);
|
var response_body = std.Io.Writer.Allocating.init(self.allocator);
|
||||||
errdefer response_body.deinit();
|
errdefer response_body.deinit();
|
||||||
|
|
||||||
const fetch_options = http.Client.FetchOptions{
|
const fetch_options = http.Client.FetchOptions{
|
||||||
.method = request.method,
|
.method = request.method,
|
||||||
.location = .{ .url = request.url },
|
.location = .{ .url = request.url },
|
||||||
.payload = if (request.payload) |p| p else null,
|
.payload = if (request.payload) |p| p else null,
|
||||||
.response_storage = .{ .dynamic = &response_body },
|
.response_writer = &response_body.writer,
|
||||||
.max_append_size = request.max_response_size,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = try self.client.fetch(fetch_options);
|
const result = try self.client.fetch(fetch_options);
|
||||||
|
|
@ -469,7 +468,7 @@ pub const MockHttpClient = struct {
|
||||||
pub fn init(allocator: std.mem.Allocator) Self {
|
pub fn init(allocator: std.mem.Allocator) Self {
|
||||||
return Self{
|
return Self{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.responses = std.ArrayList(FetchResponse).init(allocator),
|
.responses = std.ArrayList(FetchResponse).empty,
|
||||||
.request_count = std.atomic.Value(u32).init(0),
|
.request_count = std.atomic.Value(u32).init(0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -478,12 +477,12 @@ pub const MockHttpClient = struct {
|
||||||
for (self.responses.items) |*response| {
|
for (self.responses.items) |*response| {
|
||||||
self.allocator.free(response.body);
|
self.allocator.free(response.body);
|
||||||
}
|
}
|
||||||
self.responses.deinit();
|
self.responses.deinit(self.allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn addResponse(self: *Self, status: http.Status, body: []const u8) !void {
|
pub fn addResponse(self: *Self, status: http.Status, body: []const u8) !void {
|
||||||
const body_copy = try self.allocator.dupe(u8, body);
|
const body_copy = try self.allocator.dupe(u8, body);
|
||||||
try self.responses.append(FetchResponse{
|
try self.responses.append(self.allocator, FetchResponse{
|
||||||
.status = status,
|
.status = status,
|
||||||
.body = body_copy,
|
.body = body_copy,
|
||||||
.allocator = self.allocator,
|
.allocator = self.allocator,
|
||||||
|
|
@ -505,7 +504,7 @@ pub const MockHttpClient = struct {
|
||||||
_ = request;
|
_ = request;
|
||||||
|
|
||||||
if (self.delay_ms > 0) {
|
if (self.delay_ms > 0) {
|
||||||
std.time.sleep(std.time.ns_per_ms * self.delay_ms);
|
std.Thread.sleep(std.time.ns_per_ms * self.delay_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self.should_fail) {
|
if (self.should_fail) {
|
||||||
|
|
@ -611,7 +610,7 @@ test "DownloadWorker basic functionality" {
|
||||||
const thread = try std.Thread.spawn(.{}, DownloadWorker.run, .{&worker});
|
const thread = try std.Thread.spawn(.{}, DownloadWorker.run, .{&worker});
|
||||||
|
|
||||||
// Let it run for a bit
|
// Let it run for a bit
|
||||||
std.time.sleep(std.time.ns_per_ms * 100);
|
std.Thread.sleep(std.time.ns_per_ms * 100);
|
||||||
|
|
||||||
// Advance timer to trigger stop
|
// Advance timer to trigger stop
|
||||||
mock_timer.setTime(std.time.ns_per_s * 3); // 3 seconds
|
mock_timer.setTime(std.time.ns_per_s * 3); // 3 seconds
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
const cli = @import("cli/root.zig");
|
const cli = @import("cli/root.zig");
|
||||||
|
|
||||||
pub const std_options: std.Options = .{
|
pub const std_options: std.Options = .{
|
||||||
|
|
@ -10,8 +11,12 @@ pub const std_options: std.Options = .{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn main() !void {
|
pub fn main() !void {
|
||||||
const allocator = std.heap.page_allocator;
|
const allocator = std.heap.smp_allocator;
|
||||||
var root = try cli.build(allocator);
|
|
||||||
|
const file = std.fs.File.stdout();
|
||||||
|
var writer = file.writerStreaming(&.{}).interface;
|
||||||
|
|
||||||
|
const root = try cli.build(&writer, allocator);
|
||||||
defer root.deinit();
|
defer root.deinit();
|
||||||
|
|
||||||
try root.execute(.{});
|
try root.execute(.{});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue