Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ tmp/
temp/
.claude/

NUL
27 changes: 27 additions & 0 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! and subcommands (vmu zig/zls).

const std = @import("std");

const errors = @import("core/errors.zig");

/// Supported shell types for completion generation.
Expand All @@ -21,6 +22,7 @@ pub const Command = enum {
vmu,
mirrorlist,
proxy,
probe,
completion,
version,
help,
Expand All @@ -38,6 +40,7 @@ pub const InstallFlags = packed struct {
zls: bool = false,
full: bool = false,
nomirror: bool = false,
reprobe: bool = false,
};

/// Flags for the list command.
Expand Down Expand Up @@ -83,6 +86,7 @@ pub const ParsedCommand = union(Command) {
proxy: struct {
url: ?[]const u8,
},
probe,
completion: struct {
shell: ShellType,
},
Expand Down Expand Up @@ -112,6 +116,7 @@ const command_aliases = std.StaticStringMap(Command).initComptime(.{
.{ "vmu", .vmu },
.{ "mirrorlist", .mirrorlist },
.{ "proxy", .proxy },
.{ "probe", .probe },
.{ "completion", .completion },
.{ "version", .version },
.{ "help", .help },
Expand Down Expand Up @@ -177,6 +182,7 @@ pub fn parse(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !stru
.vmu => try parseVmu(allocator, &args),
.mirrorlist => try parseMirrorlist(allocator, &args),
.proxy => try parseProxy(allocator, &args),
.probe => parseProbe(&args),
.completion => try parseCompletion(&args),
.version => ParsedCommand.version,
.help => ParsedCommand{ .help = null },
Expand Down Expand Up @@ -224,6 +230,8 @@ fn parseInstall(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
flags.full = true;
} else if (std.mem.eql(u8, arg, "--nomirror")) {
flags.nomirror = true;
} else if (std.mem.eql(u8, arg, "--test") or std.mem.eql(u8, arg, "-t")) {
flags.reprobe = true;
} else {
version = try allocator.dupe(u8, arg);
}
Expand Down Expand Up @@ -330,6 +338,13 @@ fn parseProxy(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
return .{ .proxy = .{ .url = null } };
}

fn parseProbe(args: anytype) ParsedCommand {
if (args.next()) |arg| {
if (checkHelp(.probe, arg)) |h| return h;
}
return ParsedCommand.probe;
}

fn parseCompletion(args: anytype) !ParsedCommand {
const shell_str = args.next() orelse return error.MissingArgument;
if (checkHelp(.completion, shell_str)) |h| return h;
Expand Down Expand Up @@ -361,6 +376,7 @@ pub fn printHelp(writer: *std.Io.Writer) !void {
\\ vmu Set version map source (zig/zls)
\\ mirrorlist Set mirror distribution server
\\ proxy Set HTTP/HTTPS proxy for downloads
\\ probe Test mirror speeds without installing
\\ completion Generate shell completion script
\\ version Print zvm version
\\ help Print this help message
Expand Down Expand Up @@ -389,6 +405,7 @@ pub fn printCommandHelp(writer: *std.Io.Writer, cmd: Command) !void {
\\ --zls Also install ZLS (Zig Language Server)
\\ --full Install ZLS with full compatibility mode
\\ --nomirror Skip community mirror downloads
\\ --test, -t Force re-probe mirrors (ignore cache)
\\
\\Examples:
\\ zvm install master Install latest nightly
Expand Down Expand Up @@ -488,6 +505,16 @@ pub fn printCommandHelp(writer: *std.Io.Writer, cmd: Command) !void {
\\ zvm proxy Show current proxy setting
\\
),
.probe => try writer.writeAll(
\\Test mirror download speeds without installing anything.
\\
\\Downloads data from each mirror for 5 seconds and measures
\\throughput. Results are displayed in real-time.
\\
\\Usage:
\\ zvm probe
\\
),
.completion => try writer.writeAll(
\\Generate shell completion script.
\\
Expand Down
2 changes: 1 addition & 1 deletion src/command/install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ fn installVersion(
try http_client.downloadToFileWithProxy(allocator, zvm.io, zvm.environ_map, tar_url, archive_path, zvm.settings.proxy, stdout);
break :blk tar_url;
} else blk: {
const mirror_url = http_client.attemptMirrorDownload(allocator, zvm.io, zvm.environ_map, zvm.settings.mirror_list_url, tar_url, archive_path, stdout, stdout, &zvm.settings) catch {
const mirror_url = http_client.attemptMirrorDownload(allocator, zvm.io, zvm.environ_map, zvm.settings.mirror_list_url, tar_url, archive_path, stdout, stdout, &zvm.settings, flags.reprobe) catch {
try http_client.downloadToFileWithProxy(allocator, zvm.io, zvm.environ_map, tar_url, archive_path, zvm.settings.proxy, stdout);
break :blk tar_url;
};
Expand Down
106 changes: 106 additions & 0 deletions src/command/probe.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Probe command — test mirror download speeds without installing.
//! Fetches the version map and mirror list, probes all mirrors for
//! latency and throughput, and displays sorted results.

const std = @import("std");

const Console = @import("../core/Console.zig");
const platform = @import("../core/platform.zig");
const zvm_mod = @import("../core/zvm.zig");
const http_client = @import("../network/http_client.zig");
const mirror_probe = @import("../network/mirror_probe.zig");
const version_map = @import("../network/version_map.zig");

pub fn run(
zvm: *zvm_mod.ZVM,
allocator: std.mem.Allocator,
console: Console,
) !void {
const stdout = console.stdout.writer;
const proxy = zvm.settings.proxy;

// 1. Fetch version map to get a real tar URL for probing
console.plain("Fetching version map...", .{});
const parsed_map = version_map.fetchVersionMap(allocator, zvm.io, zvm.environ_map, zvm.settings.version_map_url, proxy) catch {
console.err("Failed to fetch version map", .{});
return;
};
defer parsed_map.deinit();
const vmap = &parsed_map.value.object;

// 2. Get master tar URL for the current platform
const sys_info = platform.zigStyleSystemInfo();
var plat_buf: [128]u8 = undefined;
const target = platform.platformTarget(&plat_buf, sys_info);

const tar_url = version_map.getTarPath("master", target, vmap) catch {
console.err("Failed to find download for your platform", .{});
return;
};

const filename = if (std.mem.lastIndexOfScalar(u8, tar_url, '/')) |idx| tar_url[idx + 1 ..] else tar_url;

// 3. Fetch mirror list
if (zvm.settings.mirror_list_url.len == 0) {
console.err("No mirror list configured. Use 'zvm mirrorlist <url>' to set one.", .{});
return;
}

const mirror_list_content = http_client.downloadToMemoryWithProxy(allocator, zvm.io, zvm.environ_map, zvm.settings.mirror_list_url, proxy) catch {
console.err("Failed to fetch mirror list", .{});
return;
};
defer allocator.free(mirror_list_content);

// 4. Parse mirrors (one URL per line)
var mirrors: std.ArrayList([]const u8) = .empty;
defer mirrors.deinit(allocator);

var lines = std.mem.splitSequence(u8, mirror_list_content, "\n");
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \r");
if (trimmed.len == 0) continue;
try mirrors.append(allocator, trimmed);
}

if (mirrors.items.len == 0) {
console.err("No mirrors found in mirror list", .{});
return;
}

// 5. Probe all mirrors
var candidates: std.ArrayList(mirror_probe.MirrorCandidate) = .empty;
defer {
for (candidates.items) |c| {
if (c.owned) allocator.free(c.url);
}
candidates.deinit(allocator);
}

try mirror_probe.probeAll(allocator, zvm.io, zvm.environ_map, tar_url, &mirrors, filename, proxy, &candidates, stdout);

// 6. Sort by bandwidth (with latency tiebreaker)
std.mem.sort(mirror_probe.MirrorCandidate, candidates.items, {}, mirror_probe.greaterThanByBandwidth);

// 7. Display sorted summary
if (candidates.items.len == 0) {
console.plain(" No mirrors responded.", .{});
return;
}

try stdout.print("\n Summary (sorted by speed):\n", .{});
for (candidates.items, 1..) |candidate, rank| {
var latency_buf: [64]u8 = undefined;
var speed_buf: [64]u8 = undefined;
const lat_str = if (candidate.latency_ns > 0)
mirror_probe.formatLatency(&latency_buf, candidate.latency_ns)
else
"?";
const spd_str = mirror_probe.formatThroughput(&speed_buf, candidate.bandwidth_bps);
const marker = if (rank == 1) " <-- fastest" else "";
try stdout.print(" {d:>3}. {s:<32} latency: {s:<10} speed: {s}/s{s}\n", .{
rank, mirror_probe.shortUrl(candidate.url), lat_str, spd_str, marker,
});
}
try stdout.flush();
}
4 changes: 4 additions & 0 deletions src/completion.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! new commands trigger a compile error here until their metadata is added.

const std = @import("std");

const cli = @import("cli.zig");
const Console = @import("core/Console.zig");

Expand Down Expand Up @@ -96,6 +97,9 @@ fn cmdMeta(cmd: cli.Command) CmdMeta {
.desc = "Set HTTP/HTTPS proxy for downloads",
.arg = .url,
},
.probe => .{
.desc = "Test mirror speeds without installing",
},
.completion => .{
.desc = "Generate shell completion script",
.arg = .shell_type,
Expand Down
9 changes: 6 additions & 3 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
const std = @import("std");
const Io = std.Io;
const build_options = @import("build_options");
/// Current version of zvm, injected at build time from git tag or -Dversion=.
const VERSION = build_options.version;

const cli = @import("cli.zig");
const Console = @import("core/Console.zig");
Expand All @@ -14,9 +16,6 @@ const platform = @import("core/platform.zig");
const update_check = @import("core/update_check.zig");
const zvm_mod = @import("core/zvm.zig");

/// Current version of zvm, injected at build time from git tag or -Dversion=.
const VERSION = build_options.version;

/// Full version string with 'v' prefix and git commit hash.
fn fullVersion() []const u8 {
return "v" ++ VERSION ++ " (" ++ build_options.git_commit ++ ")";
Expand Down Expand Up @@ -128,6 +127,10 @@ pub fn main(init: std.process.Init) !void {
const proxy_mod = @import("command/proxy.zig");
proxy_mod.run(&zvm, allocator, proxy_cmd.url, console) catch |err| commandFail(console, err);
},
.probe => {
const probe = @import("command/probe.zig");
probe.run(&zvm, allocator, console) catch |err| commandFail(console, err);
},
.completion => |comp_cmd| {
const completion = @import("completion.zig");
completion.run(comp_cmd.shell, console) catch |err| commandFail(console, err);
Expand Down
28 changes: 14 additions & 14 deletions src/network/http_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

const std = @import("std");
const builtin = @import("builtin");

const settings_mod = @import("../core/settings.zig");
const mirror_probe = @import("mirror_probe.zig");
const proxy_tunnel = @import("proxy_tunnel.zig");
Expand Down Expand Up @@ -305,12 +306,13 @@ pub fn attemptMirrorDownload(
verbose_writer: ?*std.Io.Writer,
progress_writer: ?*std.Io.Writer,
settings: *settings_mod.Settings,
force_probe: bool,
) ![]const u8 {
// Extract filename once (used for constructing mirror URLs and extracting base URL)
const filename = if (std.mem.lastIndexOfScalar(u8, original_url, '/')) |idx| original_url[idx + 1 ..] else original_url;

// --- Cache-fast path: try cached mirror if fresh ---
if (settings.preferred_mirror.len > 0 and settings.mirror_updated_at > 0) {
if (!force_probe and settings.preferred_mirror.len > 0 and settings.mirror_updated_at > 0) {
const now = std.Io.Clock.Timestamp.now(io, .real).raw.toSeconds();
const age = now - settings.mirror_updated_at;
if (age >= 0 and age < MIRROR_CACHE_TTL) {
Expand Down Expand Up @@ -388,7 +390,7 @@ fn probeAndDownload(
var candidates: std.ArrayList(mirror_probe.MirrorCandidate) = .empty;
defer candidates.deinit(allocator);

// Probe all candidates concurrently
// Probe all candidates sequentially
try mirror_probe.probeAll(allocator, io, environ_map, original_url, &mirrors, filename, proxy, &candidates, progress_writer);

// If no candidates responded, fall back to the original URL
Expand All @@ -397,23 +399,21 @@ fn probeAndDownload(
return allocator.dupe(u8, original_url);
}

// Sort candidates by latency (fastest first)
std.mem.sort(mirror_probe.MirrorCandidate, candidates.items, {}, mirror_probe.lessThanByLatency);
// Sort candidates by bandwidth (fastest first)
std.mem.sort(mirror_probe.MirrorCandidate, candidates.items, {}, mirror_probe.greaterThanByBandwidth);

// Print latency results if verbose output is requested
// Show which mirror was selected
if (verbose_writer) |vw| {
var time_buf: [64]u8 = undefined;
try vw.print(" Probed {d} source(s):\n", .{candidates.items.len});
for (candidates.items, 1..) |candidate, rank| {
const time_str = mirror_probe.formatLatency(&time_buf, candidate.latency_ns);
const marker = if (rank == 1) " <-- fastest" else "";
const short_url = mirror_probe.shortUrl(candidate.url);
try vw.print(" {d}. {s} ({s}{s})\n", .{ rank, short_url, time_str, marker });
}
const best = candidates.items[0];
var speed_buf: [64]u8 = undefined;
var lat_buf: [64]u8 = undefined;
const spd_str = mirror_probe.formatThroughput(&speed_buf, best.bandwidth_bps);
const lat_str = mirror_probe.formatLatency(&lat_buf, best.latency_ns);
try vw.print(" Selected: {s} ({s}/s, latency: {s})\n\n", .{ mirror_probe.shortUrl(best.url), spd_str, lat_str });
try vw.flush();
}

// Try downloading from candidates in latency order
// Try downloading from candidates in bandwidth order
for (candidates.items, 0..) |candidate, idx| {
downloadToFileWithProxy(allocator, io, environ_map, candidate.url, dest_path, proxy, progress_writer) catch {
continue;
Expand Down
Loading
Loading