a73x

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
refactor: `-d` is a word in start's second slot, not a flag on every verb

As a flag in `Opts`, `-d` parsed on `dump`, `stop` and `endpoint` too and
vanished there, and the child's line had to be rebuilt by a walk that
re-derived cliflags' arity — reading the field types and ignoring the
alias table, so the first alias added would strip a `-s`'s value.

The usage line already draws it where it can be read in one slot:
`mux d start [-d] …`. parseArgs peels args[2] and hands cliflags args[3..],
which IS the child's line, verbatim. No other verb can see the word,
nothing rewrites the daemon's argv, and `start --sock P -d` is the
unknown argument the grammar always implied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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