4aeadd38
refactor: `-d` is a word in start's second slot, not a flag on every verb
a73x 2026-08-30 10:06
Commit message
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,7 +22,7 @@ const usage = | |||
| 22 | \\usage: | 22 | \\usage: |
| 23 | \\ mux d start [-d] [--sock PATH] [--shell PATH] [--cols N] [--rows N] | 23 | \\ mux d start [-d] [--sock PATH] [--shell PATH] [--cols N] [--rows N] |
| 24 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] | 24 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] |
| 25 | \\ (-d forks it off and waits; no-op if one is up) | 25 | \\ (-d comes first; forks it off and waits, no-op if up) |
| 26 | \\ mux d dump [--vt] [--session NAME] [--sock PATH] | 26 | \\ mux d dump [--vt] [--session NAME] [--sock PATH] |
| 27 | \\ mux d stats [--sock PATH] | 27 | \\ mux d stats [--sock PATH] |
| 28 | \\ mux d stop [--sock PATH] (ask the daemon on PATH to exit) | 28 | \\ mux d stop [--sock PATH] (ask the daemon on PATH to exit) |
| @@ -125,6 +125,12 @@ const Opts = struct { | |||
| 125 | /// Not a flag, and the leading underscore is what says so: cliflags.parse | 125 | /// Not a flag, and the leading underscore is what says so: cliflags.parse |
| 126 | /// skips it, the verb having been settled by the row above. | 126 | /// skips it, the verb having been settled by the row above. |
| 127 | _cmd: Cmd, | 127 | _cmd: Cmd, |
| 128 | /// `start`'s alone, and structurally so: `-d` is a WORD `parseArgs` | ||
| 129 | /// peels at args[2], never a flag in this table, so no other verb can | ||
| 130 | /// see it and no `--sock -d` can hide one. Set means fork, hand the | ||
| 131 | /// child args[3..] verbatim, and wait until the socket answers. Off is | ||
| 132 | /// the foreground daemon — the process that binds is the one typed. | ||
| 133 | _detach: bool = false, | ||
| 128 | sock: ?[]const u8 = null, | 134 | sock: ?[]const u8 = null, |
| 129 | shell: ?[]const u8 = null, | 135 | shell: ?[]const u8 = null, |
| 130 | cols: u16 = 80, | 136 | cols: u16 = 80, |
| @@ -142,10 +148,6 @@ const Opts = struct { | |||
| 142 | /// is offered to the type: `""` is the wire's own default spelling — no | 148 | /// is offered to the type: `""` is the wire's own default spelling — no |
| 143 | /// tail at all — and would fail a rule written for a name a user typed. | 149 | /// tail at all — and would fail a rule written for a name a user typed. |
| 144 | session: ?proto.SessionName = null, | 150 | session: ?proto.SessionName = null, |
| 145 | /// `start`'s alone: fork, hand the child the same line without this | ||
| 146 | /// flag, and wait until the socket answers. Off is the foreground | ||
| 147 | /// daemon — the process that binds is the one that was typed. | ||
| 148 | detach: bool = false, | ||
| 149 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a | 151 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a |
| 150 | /// user flag: the old daemon writes it into our argv. Its presence is | 152 | /// user flag: the old daemon writes it into our argv. Its presence is |
| 151 | /// what makes `start` an ADOPTION rather than a start, and it is also | 153 | /// what makes `start` an ADOPTION rather than a start, and it is also |
| @@ -172,11 +174,6 @@ const Opts = struct { | |||
| 172 | /// daemon" holds by argv: a poll spells `mux d endpoint` and starts | 174 | /// daemon" holds by argv: a poll spells `mux d endpoint` and starts |
| 173 | /// nothing, an ask spells this. | 175 | /// nothing, an ask spells this. |
| 174 | start: bool = false, | 176 | start: bool = false, |
| 175 | |||
| 176 | /// `-d` is what everyone types and the only spelling the prose offers; | ||
| 177 | /// `--detach` is the field's own name and works because cliflags reads | ||
| 178 | /// the struct. | ||
| 179 | pub const aliases = .{.{ "-d", "detach" }}; | ||
| 180 | }; | 177 | }; |
| 181 | 178 | ||
| 182 | // The three flags left out are written into argv by the OLD daemon on an | 179 | // The three flags left out are written into argv by the OLD daemon on an |
| @@ -216,7 +213,22 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 216 | } | 213 | } |
| 217 | 214 | ||
| 218 | var o: Opts = .{ ._cmd = spec.cmd }; | 215 | var o: Opts = .{ ._cmd = spec.cmd }; |
| 219 | switch (cliflags.parse(Opts, &o, args[2..])) { | 216 | |
| 217 | // `-d` is a word in ONE position — `mux d start -d …`, exactly where | ||
| 218 | // the usage line draws it — and not a flag in `Opts` at all. That is | ||
| 219 | // the whole scoping rule: `mux d dump -d` and `mux d start --sock P | ||
| 220 | // -d` are `unknown argument: -d` because this line is the only reader | ||
| 221 | // of the word and it reads one slot. A flag would have to be refused | ||
| 222 | // on every other verb by hand, and stripped back out of the child's | ||
| 223 | // line by a walk that re-derives cliflags' arity. | ||
| 224 | const flag_args = if (spec.cmd == .start and args.len > 2 and | ||
| 225 | (std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach"))) | ||
| 226 | blk: { | ||
| 227 | o._detach = true; | ||
| 228 | break :blk args[3..]; | ||
| 229 | } else args[2..]; | ||
| 230 | |||
| 231 | switch (cliflags.parse(Opts, &o, flag_args)) { | ||
| 220 | .ok => {}, | 232 | .ok => {}, |
| 221 | .help => return .{ .err = .help }, | 233 | .help => return .{ .err = .help }, |
| 222 | // The verb the line opened with is discarded: `--version` anywhere | 234 | // The verb the line opened with is discarded: `--version` anywhere |
| @@ -1230,16 +1242,17 @@ fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { | |||
| 1230 | } | 1242 | } |
| 1231 | 1243 | ||
| 1232 | /// `mux d start`: the daemon, in this process, or — under `-d` — in a | 1244 | /// `mux d start`: the daemon, in this process, or — under `-d` — in a |
| 1233 | /// child of it. Everything after `start` is forwarded to the child | 1245 | /// child of it. `forwarded` is everything after `start`; under `-d` its |
| 1234 | /// verbatim minus the `-d` itself, so a flag that parses here behaves | 1246 | /// first word IS the `-d`, so the child's line is the rest of it |
| 1235 | /// identically there. parseArgs has already validated the flags in THIS | 1247 | /// verbatim and a flag that parses here behaves identically there. |
| 1236 | /// process; what it cannot validate (a bad bind address, a missing key | 1248 | /// parseArgs has already validated the flags in THIS process; what it |
| 1237 | /// file) surfaces in the daemon's log, which the failure path names. | 1249 | /// cannot validate (a bad bind address, a missing key file) surfaces in |
| 1250 | /// the daemon's log, which the failure path names. | ||
| 1238 | fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { | 1251 | fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { |
| 1239 | if (!o.detach) return run(alloc, o, sock_path); | 1252 | if (!o._detach) return run(alloc, o, sock_path); |
| 1240 | const child_args = try withoutDetach(alloc, forwarded); | 1253 | // A `-d` still in the child's argv would fork again, and its child |
| 1241 | defer alloc.free(child_args); | 1254 | // again, for as long as the machine lasted. |
| 1242 | const r = startDetached(alloc, child_args, sock_path, "mux d") orelse return 1; | 1255 | const r = startDetached(alloc, forwarded[1..], sock_path, "mux d") orelse return 1; |
| 1243 | if (r == .already_running) { | 1256 | if (r == .already_running) { |
| 1244 | std.debug.print( | 1257 | std.debug.print( |
| 1245 | "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n", | 1258 | "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n", |
| @@ -1249,46 +1262,6 @@ fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded: | |||
| 1249 | return 0; | 1262 | return 0; |
| 1250 | } | 1263 | } |
| 1251 | 1264 | ||
| 1252 | /// `start`'s own words back, minus the flag that asked for the fork. The | ||
| 1253 | /// child is the FOREGROUND daemon: a `-d` still in its line would fork | ||
| 1254 | /// again, and its child again, for as long as the machine lasted. | ||
| 1255 | /// | ||
| 1256 | /// A word is only a flag where a flag can stand — `--shell -d` names a | ||
| 1257 | /// shell — so the walk skips each value-taking flag's value, reading the | ||
| 1258 | /// arity off `Opts` exactly as cliflags does. | ||
| 1259 | fn withoutDetach(alloc: std.mem.Allocator, args: []const [:0]const u8) ![]const [:0]const u8 { | ||
| 1260 | var kept: std.ArrayList([:0]const u8) = .empty; | ||
| 1261 | errdefer kept.deinit(alloc); | ||
| 1262 | var i: usize = 0; | ||
| 1263 | while (i < args.len) : (i += 1) { | ||
| 1264 | const a = args[i]; | ||
| 1265 | if (std.mem.eql(u8, a, "-d") or std.mem.eql(u8, a, "--detach")) continue; | ||
| 1266 | try kept.append(alloc, a); | ||
| 1267 | if (takesValue(a) and i + 1 < args.len) { | ||
| 1268 | i += 1; | ||
| 1269 | try kept.append(alloc, args[i]); | ||
| 1270 | } | ||
| 1271 | } | ||
| 1272 | return kept.toOwnedSlice(alloc); | ||
| 1273 | } | ||
| 1274 | |||
| 1275 | /// Whether `word` is one of `Opts`' flags that eats the word after it. | ||
| 1276 | /// Read off the struct, so a field added later is covered without a second | ||
| 1277 | /// list to keep in step. | ||
| 1278 | fn takesValue(word: []const u8) bool { | ||
| 1279 | inline for (@typeInfo(Opts).@"struct".fields) |f| { | ||
| 1280 | if (f.name[0] != '_') { | ||
| 1281 | const B = if (@typeInfo(f.type) == .optional) | ||
| 1282 | @typeInfo(f.type).optional.child | ||
| 1283 | else | ||
| 1284 | f.type; | ||
| 1285 | if (B != bool and std.mem.eql(u8, word, comptime cliflags.flagName(f.name))) | ||
| 1286 | return true; | ||
| 1287 | } | ||
| 1288 | } | ||
| 1289 | return false; | ||
| 1290 | } | ||
| 1291 | |||
| 1292 | /// How long a spawn gets to answer. One number for both starters, because | 1265 | /// How long a spawn gets to answer. One number for both starters, because |
| 1293 | /// a user who waited two seconds for `mux d start -d` must not wait a | 1266 | /// a user who waited two seconds for `mux d start -d` must not wait a |
| 1294 | /// different two seconds for the attach that starts the same daemon the | 1267 | /// different two seconds for the attach that starts the same daemon the |
| @@ -1738,7 +1711,7 @@ test "parseArgs: keygen takes no flags" { | |||
| 1738 | try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).err == .unknown_arg); | 1711 | try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).err == .unknown_arg); |
| 1739 | } | 1712 | } |
| 1740 | 1713 | ||
| 1741 | test "parseArgs: -d is start's own flag, and the rest are the daemon's" { | 1714 | test "parseArgs: -d is a word in `start`'s second slot, and everything after it is the daemon's" { |
| 1742 | const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); | 1715 | const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); |
| 1743 | try std.testing.expect(r == .ok); | 1716 | try std.testing.expect(r == .ok); |
| 1744 | try std.testing.expect(r.ok._cmd == .start); | 1717 | try std.testing.expect(r.ok._cmd == .start); |
| @@ -1747,30 +1720,33 @@ test "parseArgs: -d is start's own flag, and the rest are the daemon's" { | |||
| 1747 | // Off unless typed: the process that binds is the one that was typed, | 1720 | // Off unless typed: the process that binds is the one that was typed, |
| 1748 | // and a `start` that forked by default would put the daemon somewhere | 1721 | // and a `start` that forked by default would put the daemon somewhere |
| 1749 | // the user's shell cannot see it fail. | 1722 | // the user's shell cannot see it fail. |
| 1750 | try std.testing.expect(!r.ok.detach); | 1723 | try std.testing.expect(!r.ok._detach); |
| 1751 | 1724 | ||
| 1725 | // The peel takes the word and nothing else — args[3..] is the child's | ||
| 1726 | // line verbatim, which e2e_03 pins whole off /proc/PID/cmdline. | ||
| 1752 | const d = parse(&.{ "d", "start", "-d", "--sock", "/tmp/x.sock" }); | 1727 | const d = parse(&.{ "d", "start", "-d", "--sock", "/tmp/x.sock" }); |
| 1753 | try std.testing.expect(d.ok.detach); | 1728 | try std.testing.expect(d.ok._detach); |
| 1754 | } | 1729 | try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); |
| 1730 | try std.testing.expect(parse(&.{ "d", "start", "--detach", "--cols", "100" }).ok._detach); | ||
| 1755 | 1731 | ||
| 1756 | test "withoutDetach: the child is handed the line minus the fork, values and all" { | 1732 | // One slot, so a second `-d` is a flag nothing owns rather than a |
| 1757 | const alloc = std.testing.allocator; | 1733 | // word silently dropped from the child's line. |
| 1734 | const twice = parse(&.{ "d", "start", "-d", "-d" }); | ||
| 1735 | try std.testing.expectEqualStrings("-d", twice.err.unknown_arg); | ||
| 1758 | 1736 | ||
| 1759 | // The whole point: a `-d` still in the child's argv forks again, and | 1737 | // Late is not a spelling of first. The usage line draws `-d` where it |
| 1760 | // its child again, for as long as the machine lasts. | 1738 | // is read, and a walk that found it anywhere would have to re-derive |
| 1761 | const stripped = try withoutDetach(alloc, &.{ "-d", "--sock", "/s", "--cols", "100" }); | 1739 | // cliflags' arity to know that the `-d` below is a shell's name. |
| 1762 | defer alloc.free(stripped); | 1740 | const late = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "-d" }); |
| 1763 | try std.testing.expectEqual(@as(usize, 4), stripped.len); | 1741 | try std.testing.expect(late == .err); |
| 1764 | try std.testing.expectEqualStrings("--sock", stripped[0]); | 1742 | try std.testing.expectEqualStrings("-d", late.err.unknown_arg); |
| 1765 | try std.testing.expectEqualStrings("/s", stripped[1]); | 1743 | try std.testing.expectEqual(@as(u8, 2), usageCode(late.err)); |
| 1766 | 1744 | ||
| 1767 | // A word is only a flag where a flag can stand. `--shell -d` names a | 1745 | // A value is never a flag: `--shell -d` names a shell called `-d`, and |
| 1768 | // shell called `-d`, and a blind filter would hand the daemon | 1746 | // it rides into the child untouched because nothing rewrites the line. |
| 1769 | // `--shell --cols` instead. | 1747 | const shell = parse(&.{ "d", "start", "-d", "--shell", "-d" }); |
| 1770 | const value = try withoutDetach(alloc, &.{ "--shell", "-d", "--cols", "100" }); | 1748 | try std.testing.expect(shell.ok._detach); |
| 1771 | defer alloc.free(value); | 1749 | try std.testing.expectEqualStrings("-d", shell.ok.shell.?); |
| 1772 | try std.testing.expectEqual(@as(usize, 4), value.len); | ||
| 1773 | try std.testing.expectEqualStrings("-d", value[1]); | ||
| 1774 | } | 1750 | } |
| 1775 | 1751 | ||
| 1776 | test "parseArgs: stop is a command and takes --sock" { | 1752 | test "parseArgs: stop is a command and takes --sock" { |
| @@ -1810,7 +1786,7 @@ test "parseArgs: endpoint is a command and takes --sock" { | |||
| 1810 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); | 1786 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); |
| 1811 | } | 1787 | } |
| 1812 | 1788 | ||
| 1813 | test "parseArgs: --start belongs to endpoint alone, and every other verb refuses it" { | 1789 | test "parseArgs: a verb-scoped word is refused on every verb but its own" { |
| 1814 | // The flag that makes a cold `mux HOST` one ssh run: the client no | 1790 | // The flag that makes a cold `mux HOST` one ssh run: the client no |
| 1815 | // longer decides to start, so the word has to reach this side. | 1791 | // longer decides to start, so the word has to reach this side. |
| 1816 | const on = parse(&.{ "d", "endpoint", "--start" }); | 1792 | const on = parse(&.{ "d", "endpoint", "--start" }); |
| @@ -1833,17 +1809,27 @@ test "parseArgs: --start belongs to endpoint alone, and every other verb refuses | |||
| 1833 | // words after `--version`/`--help` are ACCEPTED and vanish (see Spec), | 1809 | // words after `--version`/`--help` are ACCEPTED and vanish (see Spec), |
| 1834 | // and e2e pins that for an over-long `--sock`. Narrowing them here | 1810 | // and e2e pins that for an over-long `--sock`. Narrowing them here |
| 1835 | // would be that contract's change, not this flag's. | 1811 | // would be that contract's change, not this flag's. |
| 1812 | // | ||
| 1813 | // `-d` walks the same table for the same reason. It is not in `Opts` | ||
| 1814 | // at all — the only reader is `parseArgs`' one slot — so this loop is | ||
| 1815 | // asking whether that structure holds, not whether a refusal was | ||
| 1816 | // remembered for each verb. | ||
| 1817 | const scoped = .{ .{ "--start", Cmd.endpoint }, .{ "-d", Cmd.start } }; | ||
| 1836 | inline for (specs) |s| { | 1818 | inline for (specs) |s| { |
| 1837 | if (s.cmd == .endpoint or s.flags == .ignored) continue; | 1819 | if (s.flags == .ignored) continue; |
| 1838 | const r = parseArgs(&.{ "d", nameZ(s.name), "--start" }); | 1820 | inline for (scoped) |w| { |
| 1839 | // expect() alone prints "expected true", which does not say which | 1821 | if (s.cmd != w[1]) { |
| 1840 | // verb let the flag through. | 1822 | const r = parseArgs(&.{ "d", nameZ(s.name), w[0] }); |
| 1841 | if (r != .err) std.debug.print( | 1823 | // expect() alone prints "expected true", which does not |
| 1842 | "`mux d {s} --start` was accepted; --start is endpoint's alone\n", | 1824 | // say which verb let which word through. |
| 1843 | .{s.name}, | 1825 | if (r != .err) std.debug.print( |
| 1844 | ); | 1826 | "`mux d {s} {s}` was accepted; {s} belongs to one verb\n", |
| 1845 | try std.testing.expect(r == .err); | 1827 | .{ s.name, w[0], w[0] }, |
| 1846 | try std.testing.expectEqual(@as(u8, 2), usageCode(r.err)); | 1828 | ); |
| 1829 | try std.testing.expect(r == .err); | ||
| 1830 | try std.testing.expectEqual(@as(u8, 2), usageCode(r.err)); | ||
| 1831 | } | ||
| 1832 | } | ||
| 1847 | } | 1833 | } |
| 1848 | } | 1834 | } |
| 1849 | 1835 | ||