Skip to content

Commit d513873

Browse files
committed
feat: bandwidth-based mirror probing and standalone probe command
Replace latency-only mirror probing with actual throughput measurement. Each mirror is tested by downloading data for 5 seconds to NUL (/dev/null), measuring real download speed. This ensures mirrors with low latency but poor bandwidth are no longer incorrectly prioritized. New features: - \zvm probe\: standalone command to test mirror speeds without installing - \--test\ / \-t\ flag on \zvm install\: force re-probe, ignore 24h cache - \--help\ / \-h\ support for all subcommands - Latency tiebreaker: when bandwidth differs by <10%, lower latency wins Technical changes: - mirror_probe.zig: rewrite to sequential probing with NUL device I/O, same stream+flush pattern as the real download (streamBodyToFile) - http_client.zig: add force_probe parameter to attemptMirrorDownload, update sort to use greaterThanByBandwidth, fix dead code - probe.zig: new command implementing standalone mirror speed test - cli.zig, completion.zig, main.zig: register probe command and --help/--test flags across CLI, completion, and dispatch
1 parent 7eae454 commit d513873

8 files changed

Lines changed: 404 additions & 310 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ tmp/
4040
temp/
4141
.claude/
4242

43+
NUL

src/cli.zig

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! and subcommands (vmu zig/zls).
55

66
const std = @import("std");
7+
78
const errors = @import("core/errors.zig");
89

910
/// Supported shell types for completion generation.
@@ -21,6 +22,7 @@ pub const Command = enum {
2122
vmu,
2223
mirrorlist,
2324
proxy,
25+
probe,
2426
completion,
2527
version,
2628
help,
@@ -38,6 +40,7 @@ pub const InstallFlags = packed struct {
3840
zls: bool = false,
3941
full: bool = false,
4042
nomirror: bool = false,
43+
reprobe: bool = false,
4144
};
4245

4346
/// Flags for the list command.
@@ -83,11 +86,12 @@ pub const ParsedCommand = union(Command) {
8386
proxy: struct {
8487
url: ?[]const u8,
8588
},
89+
probe,
8690
completion: struct {
8791
shell: ShellType,
8892
},
8993
version,
90-
help,
94+
help: ?Command,
9195
};
9296

9397
/// Global flags that apply before the command (e.g., --color).
@@ -112,6 +116,7 @@ const command_aliases = std.StaticStringMap(Command).initComptime(.{
112116
.{ "vmu", .vmu },
113117
.{ "mirrorlist", .mirrorlist },
114118
.{ "proxy", .proxy },
119+
.{ "probe", .probe },
115120
.{ "completion", .completion },
116121
.{ "version", .version },
117122
.{ "help", .help },
@@ -147,7 +152,7 @@ pub fn parse(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !stru
147152
} else if (std.mem.cutPrefix(u8, arg, "--color=")) |val| {
148153
if (parseColorValue(val)) |c| global_flags.color = c;
149154
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
150-
return .{ .global = global_flags, .cmd = .help };
155+
return .{ .global = global_flags, .cmd = @unionInit(ParsedCommand, "help", null) };
151156
} else if (std.mem.eql(u8, arg, "--version") or std.mem.eql(u8, arg, "-v")) {
152157
return .{ .global = global_flags, .cmd = .version };
153158
} else {
@@ -158,7 +163,7 @@ pub fn parse(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !stru
158163
}
159164
}
160165

161-
const cmd = maybe_cmd orelse return .{ .global = global_flags, .cmd = .help };
166+
const cmd = maybe_cmd orelse return .{ .global = global_flags, .cmd = @unionInit(ParsedCommand, "help", null) };
162167

163168
// Check if the raw command name implies --all
164169
const auto_all = if (cmd_raw) |raw|
@@ -177,9 +182,10 @@ pub fn parse(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !stru
177182
.vmu => try parseVmu(allocator, &args),
178183
.mirrorlist => try parseMirrorlist(allocator, &args),
179184
.proxy => try parseProxy(allocator, &args),
185+
.probe => parseProbe(&args),
180186
.completion => try parseCompletion(&args),
181187
.version => ParsedCommand.version,
182-
.help => ParsedCommand.help,
188+
.help => @unionInit(ParsedCommand, "help", null),
183189
};
184190

185191
return .{ .global = global_flags, .cmd = parsed };
@@ -208,14 +214,18 @@ fn parseInstall(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
208214
var version: ?[]const u8 = null;
209215

210216
while (args.next()) |arg| {
211-
if (std.mem.eql(u8, arg, "--force") or std.mem.eql(u8, arg, "-f")) {
217+
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
218+
return .{ .help = .install };
219+
} else if (std.mem.eql(u8, arg, "--force") or std.mem.eql(u8, arg, "-f")) {
212220
flags.force = true;
213221
} else if (std.mem.eql(u8, arg, "--zls")) {
214222
flags.zls = true;
215223
} else if (std.mem.eql(u8, arg, "--full")) {
216224
flags.full = true;
217225
} else if (std.mem.eql(u8, arg, "--nomirror")) {
218226
flags.nomirror = true;
227+
} else if (std.mem.eql(u8, arg, "--test") or std.mem.eql(u8, arg, "-t")) {
228+
flags.reprobe = true;
219229
} else {
220230
version = try allocator.dupe(u8, arg);
221231
}
@@ -232,7 +242,9 @@ fn parseUse(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
232242
var version: ?[]const u8 = null;
233243

234244
while (args.next()) |arg| {
235-
if (std.mem.eql(u8, arg, "--sync")) {
245+
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
246+
return @unionInit(ParsedCommand, "help", .use);
247+
} else if (std.mem.eql(u8, arg, "--sync")) {
236248
flags.sync = true;
237249
} else {
238250
version = try allocator.dupe(u8, arg);
@@ -249,7 +261,9 @@ fn parseList(args: anytype, auto_all: bool) ParsedCommand {
249261
var flags: ListFlags = .{ .all = auto_all };
250262

251263
while (args.next()) |arg| {
252-
if (std.mem.eql(u8, arg, "--all") or std.mem.eql(u8, arg, "-a")) {
264+
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
265+
return @unionInit(ParsedCommand, "help", .list);
266+
} else if (std.mem.eql(u8, arg, "--all") or std.mem.eql(u8, arg, "-a")) {
253267
flags.all = true;
254268
} else if (std.mem.eql(u8, arg, "--vmu")) {
255269
flags.vmu = true;
@@ -261,13 +275,19 @@ fn parseList(args: anytype, auto_all: bool) ParsedCommand {
261275

262276
fn parseUninstall(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
263277
const version = args.next() orelse return error.MissingArgument;
278+
if (std.mem.eql(u8, version, "--help") or std.mem.eql(u8, version, "-h")) {
279+
return @unionInit(ParsedCommand, "help", .uninstall);
280+
}
264281
return .{ .uninstall = .{
265282
.version = try allocator.dupe(u8, version),
266283
} };
267284
}
268285

269286
fn parseRun(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
270287
const version = args.next() orelse return error.MissingArgument;
288+
if (std.mem.eql(u8, version, "--help") or std.mem.eql(u8, version, "-h")) {
289+
return @unionInit(ParsedCommand, "help", .run);
290+
}
271291

272292
var run_args: std.ArrayList([]const u8) = .empty;
273293
errdefer run_args.deinit(allocator);
@@ -284,6 +304,9 @@ fn parseRun(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
284304

285305
fn parseVmu(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
286306
const subcmd = args.next() orelse return error.MissingArgument;
307+
if (std.mem.eql(u8, subcmd, "--help") or std.mem.eql(u8, subcmd, "-h")) {
308+
return @unionInit(ParsedCommand, "help", .vmu);
309+
}
287310
const value = args.next() orelse return error.MissingArgument;
288311

289312
const target: VmuTarget = if (std.mem.eql(u8, subcmd, "zig"))
@@ -301,20 +324,44 @@ fn parseVmu(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
301324

302325
fn parseMirrorlist(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
303326
const url = args.next();
304-
return .{ .mirrorlist = .{
305-
.url = if (url) |u| try allocator.dupe(u8, u) else null,
306-
} };
327+
if (url) |u| {
328+
if (std.mem.eql(u8, u, "--help") or std.mem.eql(u8, u, "-h")) {
329+
return @unionInit(ParsedCommand, "help", .mirrorlist);
330+
}
331+
return .{ .mirrorlist = .{
332+
.url = try allocator.dupe(u8, u),
333+
} };
334+
}
335+
return .{ .mirrorlist = .{ .url = null } };
307336
}
308337

309338
fn parseProxy(allocator: std.mem.Allocator, args: anytype) !ParsedCommand {
310339
const url = args.next();
311-
return .{ .proxy = .{
312-
.url = if (url) |u| try allocator.dupe(u8, u) else null,
313-
} };
340+
if (url) |u| {
341+
if (std.mem.eql(u8, u, "--help") or std.mem.eql(u8, u, "-h")) {
342+
return @unionInit(ParsedCommand, "help", .proxy);
343+
}
344+
return .{ .proxy = .{
345+
.url = try allocator.dupe(u8, u),
346+
} };
347+
}
348+
return .{ .proxy = .{ .url = null } };
349+
}
350+
351+
fn parseProbe(args: anytype) ParsedCommand {
352+
if (args.next()) |arg| {
353+
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
354+
return @unionInit(ParsedCommand, "help", .probe);
355+
}
356+
}
357+
return ParsedCommand.probe;
314358
}
315359

316360
fn parseCompletion(args: anytype) !ParsedCommand {
317361
const shell_str = args.next() orelse return error.MissingArgument;
362+
if (std.mem.eql(u8, shell_str, "--help") or std.mem.eql(u8, shell_str, "-h")) {
363+
return @unionInit(ParsedCommand, "help", .completion);
364+
}
318365
const shell: ShellType = if (std.mem.eql(u8, shell_str, "zsh"))
319366
.zsh
320367
else if (std.mem.eql(u8, shell_str, "bash"))
@@ -343,6 +390,7 @@ pub fn printHelp(writer: *std.Io.Writer) !void {
343390
\\ vmu Set version map source (zig/zls)
344391
\\ mirrorlist Set mirror distribution server
345392
\\ proxy Set HTTP/HTTPS proxy for downloads
393+
\\ probe Test mirror speeds without installing
346394
\\ completion Generate shell completion script
347395
\\ version Print zvm version
348396
\\ help Print this help message
@@ -371,6 +419,7 @@ pub fn printCommandHelp(writer: *std.Io.Writer, cmd: Command) !void {
371419
\\ --zls Also install ZLS (Zig Language Server)
372420
\\ --full Install ZLS with full compatibility mode
373421
\\ --nomirror Skip community mirror downloads
422+
\\ --test, -t Force re-probe mirrors (ignore cache)
374423
\\
375424
\\Examples:
376425
\\ zvm install master Install latest nightly
@@ -470,6 +519,27 @@ pub fn printCommandHelp(writer: *std.Io.Writer, cmd: Command) !void {
470519
\\ zvm proxy Show current proxy setting
471520
\\
472521
),
522+
.probe => try writer.writeAll(
523+
\\Test mirror download speeds without installing anything.
524+
\\
525+
\\Downloads data from each mirror for 5 seconds and measures
526+
\\throughput. Results are displayed in real-time.
527+
\\
528+
\\Usage:
529+
\\ zvm probe
530+
\\
531+
),
532+
.completion => try writer.writeAll(
533+
\\Generate shell completion script.
534+
\\
535+
\\Usage:
536+
\\ zvm completion <shell>
537+
\\
538+
\\Examples:
539+
\\ zvm completion zsh
540+
\\ zvm completion bash
541+
\\
542+
),
473543
.version, .help => printHelp(writer) catch {},
474544
}
475545
try writer.flush();

src/command/install.zig

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
//! Optionally installs ZLS (Zig Language Server) alongside Zig.
55

66
const std = @import("std");
7-
const zvm_mod = @import("../core/zvm.zig");
7+
88
const cli = @import("../cli.zig");
9-
const platform = @import("../core/platform.zig");
109
const Console = @import("../core/Console.zig");
11-
const version_map = @import("../network/version_map.zig");
10+
const crypto = @import("../core/crypto.zig");
11+
const platform = @import("../core/platform.zig");
12+
const zvm_mod = @import("../core/zvm.zig");
1213
const http_client = @import("../network/http_client.zig");
14+
const version_map = @import("../network/version_map.zig");
1315
const archive = @import("archive.zig");
14-
const crypto = @import("../core/crypto.zig");
1516

1617
/// Main entry point for the `zvm install` command.
1718
/// Resolves the requested version, checks if already installed,
@@ -110,7 +111,7 @@ fn installVersion(
110111
try http_client.downloadToFileWithProxy(allocator, zvm.io, zvm.environ_map, tar_url, archive_path, zvm.settings.proxy, stdout);
111112
break :blk tar_url;
112113
} else blk: {
113-
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 {
114+
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 {
114115
try http_client.downloadToFileWithProxy(allocator, zvm.io, zvm.environ_map, tar_url, archive_path, zvm.settings.proxy, stdout);
115116
break :blk tar_url;
116117
};

src/command/probe.zig

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//! Probe command — test mirror download speeds without installing.
2+
//! Fetches the version map and mirror list, probes all mirrors for
3+
//! latency and throughput, and displays sorted results.
4+
5+
const std = @import("std");
6+
7+
const Console = @import("../core/Console.zig");
8+
const platform = @import("../core/platform.zig");
9+
const zvm_mod = @import("../core/zvm.zig");
10+
const http_client = @import("../network/http_client.zig");
11+
const mirror_probe = @import("../network/mirror_probe.zig");
12+
const version_map = @import("../network/version_map.zig");
13+
14+
pub fn run(
15+
zvm: *zvm_mod.ZVM,
16+
allocator: std.mem.Allocator,
17+
console: Console,
18+
) !void {
19+
const stdout = console.stdout.writer;
20+
const proxy = zvm.settings.proxy;
21+
22+
// 1. Fetch version map to get a real tar URL for probing
23+
console.plain("Fetching version map...", .{});
24+
const parsed_map = version_map.fetchVersionMap(allocator, zvm.io, zvm.environ_map, zvm.settings.version_map_url, proxy) catch {
25+
console.err("Failed to fetch version map", .{});
26+
return;
27+
};
28+
defer parsed_map.deinit();
29+
const vmap = &parsed_map.value.object;
30+
31+
// 2. Get master tar URL for the current platform
32+
const sys_info = platform.zigStyleSystemInfo();
33+
var plat_buf: [128]u8 = undefined;
34+
const target = platform.platformTarget(&plat_buf, sys_info);
35+
36+
const tar_url = version_map.getTarPath("master", target, vmap) catch {
37+
console.err("Failed to find download for your platform", .{});
38+
return;
39+
};
40+
41+
const filename = if (std.mem.lastIndexOfScalar(u8, tar_url, '/')) |idx| tar_url[idx + 1 ..] else tar_url;
42+
43+
// 3. Fetch mirror list
44+
if (zvm.settings.mirror_list_url.len == 0) {
45+
console.err("No mirror list configured. Use 'zvm mirrorlist <url>' to set one.", .{});
46+
return;
47+
}
48+
49+
const mirror_list_content = http_client.downloadToMemoryWithProxy(allocator, zvm.io, zvm.environ_map, zvm.settings.mirror_list_url, proxy) catch {
50+
console.err("Failed to fetch mirror list", .{});
51+
return;
52+
};
53+
defer allocator.free(mirror_list_content);
54+
55+
// 4. Parse mirrors (one URL per line)
56+
var mirrors: std.ArrayList([]const u8) = .empty;
57+
defer mirrors.deinit(allocator);
58+
59+
var lines = std.mem.splitSequence(u8, mirror_list_content, "\n");
60+
while (lines.next()) |line| {
61+
const trimmed = std.mem.trim(u8, line, " \r");
62+
if (trimmed.len == 0) continue;
63+
try mirrors.append(allocator, trimmed);
64+
}
65+
66+
if (mirrors.items.len == 0) {
67+
console.err("No mirrors found in mirror list", .{});
68+
return;
69+
}
70+
71+
// 5. Probe all mirrors
72+
var candidates: std.ArrayList(mirror_probe.MirrorCandidate) = .empty;
73+
defer {
74+
for (candidates.items) |c| {
75+
if (c.owned) allocator.free(c.url);
76+
}
77+
candidates.deinit(allocator);
78+
}
79+
80+
try mirror_probe.probeAll(allocator, zvm.io, zvm.environ_map, tar_url, &mirrors, filename, proxy, &candidates, stdout);
81+
82+
// 6. Sort by bandwidth (with latency tiebreaker)
83+
std.mem.sort(mirror_probe.MirrorCandidate, candidates.items, {}, mirror_probe.greaterThanByBandwidth);
84+
85+
// 7. Display sorted summary
86+
if (candidates.items.len == 0) {
87+
console.plain(" No mirrors responded.", .{});
88+
return;
89+
}
90+
91+
try stdout.print("\n Summary (sorted by speed):\n", .{});
92+
for (candidates.items, 1..) |candidate, rank| {
93+
var latency_buf: [64]u8 = undefined;
94+
var speed_buf: [64]u8 = undefined;
95+
const lat_str = if (candidate.latency_ns > 0)
96+
mirror_probe.formatLatency(&latency_buf, candidate.latency_ns)
97+
else
98+
"?";
99+
const spd_str = mirror_probe.formatThroughput(&speed_buf, candidate.bandwidth_bps);
100+
const marker = if (rank == 1) " <-- fastest" else "";
101+
try stdout.print(" {d:>3}. {s:<32} latency: {s:<10} speed: {s}/s{s}\n", .{
102+
rank, mirror_probe.shortUrl(candidate.url), lat_str, spd_str, marker,
103+
});
104+
}
105+
try stdout.flush();
106+
}

0 commit comments

Comments
 (0)