a73x

78b8d479

feat: --version on both binaries, one source in build.zig

a73x   2026-08-09 11:30

Commit message
feat: --version on both binaries, one source in build.zig

build.zig
Old New
@@ -50,6 +50,11 @@ fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void {
50 } 50 }
51 51
52 pub fn build(b: *std.Build) void { 52 pub fn build(b: *std.Build) void {
53 // Single source for both binaries' --version. Bumped at tag time.
54 const version = "0.0.1-3";
55 const version_opts = b.addOptions();
56 version_opts.addOption([]const u8, "version", version);
57
53 const target = b.standardTargetOptions(.{}); 58 const target = b.standardTargetOptions(.{});
54 const optimize = b.standardOptimizeOption(.{}); 59 const optimize = b.standardOptimizeOption(.{});
55 const quic = quicDeps(b, target); 60 const quic = quicDeps(b, target);
@@ -155,6 +160,7 @@ pub fn build(b: *std.Build) void {
155 .link_libc = true, 160 .link_libc = true,
156 }); 161 });
157 mux_mod.addImport("client", client_mod); 162 mux_mod.addImport("client", client_mod);
163 mux_mod.addImport("build_options", version_opts.createModule());
158 164
159 // No imports that teach it anything, deliberately: the proxy is a byte 165 // No imports that teach it anything, deliberately: the proxy is a byte
160 // pump that knows nothing about the protocol it carries. `testtmp` is 166 // pump that knows nothing about the protocol it carries. `testtmp` is
@@ -196,6 +202,7 @@ pub fn build(b: *std.Build) void {
196 // The daemon entrypoint loads the key and constructs the listener, so it 202 // The daemon entrypoint loads the key and constructs the listener, so it
197 // needs the module directly rather than through the server. 203 // needs the module directly rather than through the server.
198 exe_mod.addImport("quic", quic_mod); 204 exe_mod.addImport("quic", quic_mod);
205 exe_mod.addImport("build_options", version_opts.createModule());
199 206
200 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 207 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
201 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 208 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -41,7 +41,7 @@
41 - Modify: `src/mux_main.zig` (usage ~8, `ParseResult` ~25, parse loop ~55, dispatch ~122) 41 - Modify: `src/mux_main.zig` (usage ~8, `ParseResult` ~25, parse loop ~55, dispatch ~122)
42 - Modify: `test/e2e.sh` (new scenario near the top, after helper definitions) 42 - Modify: `test/e2e.sh` (new scenario near the top, after helper definitions)
43 43
44 - [ ] **Step 1: Write the failing parse tests** 44 - [x] **Step 1: Write the failing parse tests**
45 45
46 In `src/main.zig`, append to the test section: 46 In `src/main.zig`, append to the test section:
47 47
@@ -62,12 +62,12 @@ test "parseArgs: --version wins wherever it appears" {
62 } 62 }
63 ``` 63 ```
64 64
65 - [ ] **Step 2: Run to verify both fail** 65 - [x] **Step 2: Run to verify both fail**
66 66
67 Run: `make test` 67 Run: `make test`
68 Expected: compile errors (`.version` not a member of `Cmd` / `ParseResult`). A compile error in the test is the failing state here. 68 Expected: compile errors (`.version` not a member of `Cmd` / `ParseResult`). A compile error in the test is the failing state here.
69 69
70 - [ ] **Step 3: build.zig — version constant and options module** 70 - [x] **Step 3: build.zig — version constant and options module**
71 71
72 At the top of `build()` in `build.zig` (immediately after `pub fn build(b: *std.Build) void {`): 72 At the top of `build()` in `build.zig` (immediately after `pub fn build(b: *std.Build) void {`):
73 73
@@ -90,7 +90,7 @@ After `mux_mod.addImport("client", client_mod);` (~line 157):
90 mux_mod.addImport("build_options", version_opts.createModule()); 90 mux_mod.addImport("build_options", version_opts.createModule());
91 ``` 91 ```
92 92
93 - [ ] **Step 4: muxd side** 93 - [x] **Step 4: muxd side**
94 94
95 `src/main.zig`. Add import after the existing ones (~line 8): 95 `src/main.zig`. Add import after the existing ones (~line 8):
96 96
@@ -127,7 +127,7 @@ Usage text (~line 10): add a final line before the closing `\\`:
127 \\ muxd --version 127 \\ muxd --version
128 ``` 128 ```
129 129
130 - [ ] **Step 5: mux side** 130 - [x] **Step 5: mux side**
131 131
132 `src/mux_main.zig`. Add import at top: 132 `src/mux_main.zig`. Add import at top:
133 133
@@ -164,16 +164,16 @@ In `main`'s switch (~line 122):
164 164
165 Usage (~line 8): append `\\ --version prints the version` styled like the neighbors. 165 Usage (~line 8): append `\\ --version prints the version` styled like the neighbors.
166 166
167 - [ ] **Step 6: Run tests, expect pass** 167 - [x] **Step 6: Run tests, expect pass**
168 168
169 Run: `make test` 169 Run: `make test`
170 Expected: all pass, including the two new ones. 170 Expected: all pass, including the two new ones.
171 171
172 - [ ] **Step 7: Mutation check** 172 - [x] **Step 7: Mutation check**
173 173
174 In `src/main.zig` change `.version` parse line to return `.{ .err = .no_command }`; run `make test`; the muxd test MUST fail. Restore. Same for mux: make `--version` fall through to `usage_error`; test MUST fail; restore. 174 In `src/main.zig` change `.version` parse line to return `.{ .err = .no_command }`; run `make test`; the muxd test MUST fail. Restore. Same for mux: make `--version` fall through to `usage_error`; test MUST fail; restore.
175 175
176 - [ ] **Step 8: e2e** 176 - [x] **Step 8: e2e**
177 177
178 `make build` first (e2e uses built artifacts). In `test/e2e.sh`, after the helper-function block (~line 60), add: 178 `make build` first (e2e uses built artifacts). In `test/e2e.sh`, after the helper-function block (~line 60), add:
179 179
@@ -187,7 +187,7 @@ echo "e2e OK: --version on both binaries"
187 Run: `make build && make e2e` 187 Run: `make build && make e2e`
188 Expected: `e2e OK: --version on both binaries` among the output; suite passes. 188 Expected: `e2e OK: --version on both binaries` among the output; suite passes.
189 189
190 - [ ] **Step 9: Commit** 190 - [x] **Step 9: Commit**
191 191
192 ```bash 192 ```bash
193 git add build.zig src/main.zig src/mux_main.zig test/e2e.sh 193 git add build.zig src/main.zig src/mux_main.zig test/e2e.sh
src/main.zig
Old New
@@ -6,6 +6,7 @@ const Server = @import("server").Server;
6 const proto = @import("protocol"); 6 const proto = @import("protocol");
7 const proxy = @import("proxy"); 7 const proxy = @import("proxy");
8 const quic = @import("quic"); 8 const quic = @import("quic");
9 const build_options = @import("build_options");
9 10
10 const usage = 11 const usage =
11 \\usage: 12 \\usage:
@@ -14,6 +15,7 @@ const usage =
14 \\ muxd dump [--vt] [--sock PATH] 15 \\ muxd dump [--vt] [--sock PATH]
15 \\ muxd stats [--sock PATH] 16 \\ muxd stats [--sock PATH]
16 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 17 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
18 \\ muxd --version
17 \\ 19 \\
18 ; 20 ;
19 21
@@ -23,7 +25,7 @@ const usage =
23 /// need death declared on a schedule they can wait for. 25 /// need death declared on a schedule they can wait for.
24 const default_quic_idle_ms: u32 = 15_000; 26 const default_quic_idle_ms: u32 = 15_000;
25 27
26 const Cmd = enum { run, dump, stats, proxy }; 28 const Cmd = enum { run, dump, stats, proxy, version };
27 29
28 /// Everything the command line can say, once. Parsed away from `main` so it 30 /// Everything the command line can say, once. Parsed away from `main` so it
29 /// can be tested without a process to exit from — the same reason 31 /// can be tested without a process to exit from — the same reason
@@ -60,6 +62,9 @@ const ParseResult = union(enum) { ok: Opts, err: Usage };
60 62
61 fn parseArgs(args: []const [:0]const u8) ParseResult { 63 fn parseArgs(args: []const [:0]const u8) ParseResult {
62 if (args.len < 2) return .{ .err = .no_command }; 64 if (args.len < 2) return .{ .err = .no_command };
65 // Spelled as a flag because that is what everyone types, but it is a
66 // command: it names what the process does instead of configuring one.
67 if (std.mem.eql(u8, args[1], "--version")) return .{ .ok = .{ .cmd = .version } };
63 const cmd: Cmd = if (std.mem.eql(u8, args[1], "run")) 68 const cmd: Cmd = if (std.mem.eql(u8, args[1], "run"))
64 .run 69 .run
65 else if (std.mem.eql(u8, args[1], "dump")) 70 else if (std.mem.eql(u8, args[1], "dump"))
@@ -194,6 +199,14 @@ pub fn main() !u8 {
194 defer alloc.free(sock_path); 199 defer alloc.free(sock_path);
195 200
196 switch (o.cmd) { 201 switch (o.cmd) {
202 // The socket path resolved above is unused here and harmless: asking
203 // a binary its version must work with no daemon and no runtime dir.
204 .version => {
205 var vbuf: [64]u8 = undefined;
206 const s = std.fmt.bufPrint(&vbuf, "muxd {s}\n", .{build_options.version}) catch unreachable;
207 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
208 return 0;
209 },
197 .run => return run(alloc, o, sock_path), 210 .run => return run(alloc, o, sock_path),
198 .dump => return dump(alloc, sock_path, o.vt), 211 .dump => return dump(alloc, sock_path, o.vt),
199 .stats => return stats(alloc, sock_path), 212 .stats => return stats(alloc, sock_path),
@@ -481,3 +494,9 @@ test "parseBindAddr: a hostname is refused, not resolved" {
481 // name resolving to several is a question rather than an answer. 494 // name resolving to several is a question rather than an answer.
482 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433"))); 495 try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433")));
483 } 496 }
497
498 test "parseArgs: --version is a command, not a flag on one" {
499 const r = parse(&.{ "muxd", "--version" });
500 try std.testing.expect(r == .ok);
501 try std.testing.expect(r.ok.cmd == .version);
502 }
src/mux_main.zig
Old New
@@ -4,6 +4,7 @@
4 //! `mux HOST` is sugar for exactly that ssh recipe. 4 //! `mux HOST` is sugar for exactly that ssh recipe.
5 const std = @import("std"); 5 const std = @import("std");
6 const client = @import("client"); 6 const client = @import("client");
7 const build_options = @import("build_options");
7 8
8 const usage = 9 const usage =
9 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST:PORT] 10 \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST:PORT]
@@ -11,6 +12,7 @@ const usage =
11 \\ quic://HOST:PORT needs --key FILE (or MUX_KEY_FILE); muxd must be 12 \\ quic://HOST:PORT needs --key FILE (or MUX_KEY_FILE); muxd must be
12 \\ running with a matching --quic and --key 13 \\ running with a matching --quic and --key
13 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed 14 \\ [--quic-idle-ms N] tunes how fast a dead link is noticed
15 \\ --version prints the version
14 \\ 16 \\
15 ; 17 ;
16 18
@@ -30,6 +32,9 @@ const ParseResult = union(enum) {
30 /// usage error: nothing is misspelled, something is missing, and the 32 /// usage error: nothing is misspelled, something is missing, and the
31 /// message that helps says which. 33 /// message that helps says which.
32 quic_without_key, 34 quic_without_key,
35 /// `--version`: not a transport at all, so it short-circuits the rest of
36 /// the parse rather than being reconciled with it.
37 version,
33 /// More than one transport named — a request that cannot be honoured 38 /// More than one transport named — a request that cannot be honoured
34 /// rather than one to reconcile. 39 /// rather than one to reconcile.
35 conflict, 40 conflict,
@@ -52,7 +57,12 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult {
52 var i: usize = 1; 57 var i: usize = 1;
53 while (i < args.len) : (i += 1) { 58 while (i < args.len) : (i += 1) {
54 const a = args[i]; 59 const a = args[i];
55 if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { 60 // First branch, and it returns rather than recording: asking a binary
61 // its version must answer whatever else is on the line, including a
62 // transport that would otherwise conflict or fail to parse.
63 if (std.mem.eql(u8, a, "--version")) {
64 return .version;
65 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
56 i += 1; 66 i += 1;
57 if (sock != null) return .conflict; 67 if (sock != null) return .conflict;
58 sock = args[i]; 68 sock = args[i];
@@ -120,6 +130,12 @@ pub fn main() !u8 {
120 130
121 const parsed = parseArgs(args, std.posix.getenv(key_env)); 131 const parsed = parseArgs(args, std.posix.getenv(key_env));
122 switch (parsed) { 132 switch (parsed) {
133 .version => {
134 var vbuf: [64]u8 = undefined;
135 const s = std.fmt.bufPrint(&vbuf, "mux {s}\n", .{build_options.version}) catch unreachable;
136 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
137 return 0;
138 },
123 .usage_error => { 139 .usage_error => {
124 std.debug.print("{s}", .{usage}); 140 std.debug.print("{s}", .{usage});
125 return 2; 141 return 2;
@@ -276,3 +292,8 @@ test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" {
276 try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); 292 try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error);
277 try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms" }) == .usage_error); 293 try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms" }) == .usage_error);
278 } 294 }
295
296 test "parseArgs: --version wins wherever it appears" {
297 try std.testing.expect(parse(&.{ "mux", "--version" }) == .version);
298 try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version);
299 }
test/e2e.sh
Old New
@@ -98,6 +98,11 @@ cleanup() {
98 } 98 }
99 trap cleanup EXIT INT TERM 99 trap cleanup EXIT INT TERM
100 100
101 # --- M10: --version answers "did the scp land" without a daemon anywhere.
102 "$MUXD" --version | grep -q '^muxd 0\.' || { echo "e2e FAIL: muxd --version"; exit 1; }
103 "$MUX" --version | grep -q '^mux 0\.' || { echo "e2e FAIL: mux --version"; exit 1; }
104 echo "e2e OK: --version on both binaries"
105
101 "$MUXD" run --sock "$SOCK" --shell /bin/sh & 106 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
102 DPID=$! 107 DPID=$!
103 108