78b8d479
feat: --version on both binaries, one source in build.zig
a73x 2026-08-09 11:30
Commit message
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 | ||