26643ada
refactor: no bare `mux run` — the v15 probe refuses before that exec could happen
a73x 2026-08-28 23:12
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -78,8 +78,9 @@ Layers are enforced in the same module table (grep `.layer =` for the graph). | |||
| 78 | 78 | ||
| 79 | ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, | 79 | ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, |
| 80 | `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no | 80 | `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no |
| 81 | letter at all the client. `mux run …` is the one alias — a v0.0.1-15 daemon's | 81 | letter at all the client. There is NO alias: a word that is not a mode is a |
| 82 | upgrade execs `<candidate> run --resume-fd N`. Module names in the table are | 82 | transport, so `mux run` names a host called "run" (pinned in `mux.zig`'s |
| 83 | `modeOf`). Module names in the table are | ||
| 83 | `daemon_main` `agent_main` `hub_main` `client_main`; the files keep their old | 84 | `daemon_main` `agent_main` `hub_main` `client_main`; the files keep their old |
| 84 | paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`: | 85 | paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`: |
| 85 | `ptyclient` (real client on a real pty), `wsclient` (browser stand-in), | 86 | `ptyclient` (real client on a real pty), `wsclient` (browser stand-in), |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7462,3 +7462,24 @@ binary rolled back TO is by definition the older one. | |||
| 7462 | Remote is unchanged in mechanism and changed in spelling: `handoff` builds | 7462 | Remote is unchanged in mechanism and changed in spelling: `handoff` builds |
| 7463 | `ssh HOST 'mux d endpoint'` and `'mux d start'`, so a box still running a | 7463 | `ssh HOST 'mux d endpoint'` and `'mux d start'`, so a box still running a |
| 7464 | ≤v0.0.1-15 install reads `[unreachable]` on the wall until it is upgraded. | 7464 | ≤v0.0.1-15 install reads `[unreachable]` on the wall until it is upgraded. |
| 7465 | |||
| 7466 | ## 2026-08-29 — the bare `mux run` alias is removed; it was unreachable | ||
| 7467 | |||
| 7468 | The entry above kept `mux run …` as the bridge for a daemon of v0.0.1-15 or | ||
| 7469 | older, whose upgrade execs `<candidate> run --resume-fd N`. That exec never | ||
| 7470 | happens: the same daemon runs `<candidate> --version` FIRST and demands | ||
| 7471 | `muxd <version>`, which this binary does not print, so it refuses the | ||
| 7472 | candidate before anything is exec'd. An alias reachable only through a door | ||
| 7473 | that is already shut is a second grammar for nothing, so the rule is now | ||
| 7474 | without exception — a word that is not `d`, `a` or `web` is a transport, and | ||
| 7475 | `mux run` names a host called "run" (`mux.zig`'s `modeOf`, and the refusal a | ||
| 7476 | user actually sees pinned in `mux_main.zig`). | ||
| 7477 | |||
| 7478 | `main.zig`'s rollback exec went with it: it spelled the bare `run` on the | ||
| 7479 | argument that the binary being exec'd BACK is older. It is older only by | ||
| 7480 | what this repo has shipped since, never by the rename, because a ≤v15 daemon | ||
| 7481 | can never have been the writer of a manifest this binary is resuming. It | ||
| 7482 | spells `d run` now, like every other exec here. | ||
| 7483 | |||
| 7484 | `mux d upgrade` still translates a `version: output mismatch` from that | ||
| 7485 | daemon into the one-time `mux d stop` / `mux d start`. | ||
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -435,11 +435,11 @@ fn rollback( | |||
| 435 | 435 | ||
| 436 | var fd_buf: [12]u8 = undefined; | 436 | var fd_buf: [12]u8 = undefined; |
| 437 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1; | 437 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1; |
| 438 | // Bare `run`, not `d run`: the binary being exec'd back is by | 438 | // `d run`, the only spelling there is. The binary being exec'd back is |
| 439 | // definition OLDER than this one, and v0.0.1-15 and earlier know only | 439 | // older than this one, but not by more than the rename: a daemon of |
| 440 | // the bare spelling. This binary accepts both (mux.zig's bridge), so | 440 | // v0.0.1-15 or earlier refuses this binary as a candidate at its |
| 441 | // the older word is the one that works either way. | 441 | // version probe, so it can never have been the writer here. |
| 442 | const argv = [_:null]?[*:0]const u8{ "mux", "run", "--resume-fd", fd_str.ptr }; | 442 | const argv = [_:null]?[*:0]const u8{ "mux", "d", "run", "--resume-fd", fd_str.ptr }; |
| 443 | const path_z = alloc.dupeZ(u8, writer_path) catch return 1; | 443 | const path_z = alloc.dupeZ(u8, writer_path) catch return 1; |
| 444 | const envp = rollbackEnvp(alloc) catch return 1; | 444 | const envp = rollbackEnvp(alloc) catch return 1; |
| 445 | 445 | ||
src/cli/mux.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,15 +1,28 @@ | |||
| 1 | //! mux — the one binary. The first word picks a mode: `d` is the daemon, | 1 | //! mux — the one binary. The first word picks a mode: `d` is the daemon, |
| 2 | //! `a` the JSON agent surface, `web` the browser hub, and anything else | 2 | //! `a` the JSON agent surface, `web` the browser hub, and anything else |
| 3 | //! (including nothing) is the client. Each mode's own parser then reads the | 3 | //! (including nothing) is the client. Each mode's own parser then reads the |
| 4 | //! rest, so this file owns the mode letter and nothing else: there is no | 4 | //! rest, so this file owns the mode word and nothing else: there is no |
| 5 | //! second flag grammar here, and no argv[0] dispatch — the four programs | 5 | //! second flag grammar here, no argv[0] dispatch and no alias — the four |
| 6 | //! became four words, not four names for one file. | 6 | //! programs became four words, not four names for one file. A word that is |
| 7 | //! not a mode is a TARGET: `mux run` names a host called "run". | ||
| 7 | const std = @import("std"); | 8 | const std = @import("std"); |
| 8 | const daemon = @import("daemon_main"); | 9 | const daemon = @import("daemon_main"); |
| 9 | const agent = @import("agent_main"); | 10 | const agent = @import("agent_main"); |
| 10 | const hub = @import("hub_main"); | 11 | const hub = @import("hub_main"); |
| 11 | const client = @import("client_main"); | 12 | const client = @import("client_main"); |
| 12 | 13 | ||
| 14 | /// The whole grammar, as a value, so the one decision this file makes can be | ||
| 15 | /// asked without a process to exit from. | ||
| 16 | const Mode = enum { daemon, agent, hub, client }; | ||
| 17 | |||
| 18 | fn modeOf(args: []const [:0]const u8) Mode { | ||
| 19 | if (args.len < 2) return .client; | ||
| 20 | if (std.mem.eql(u8, args[1], "d")) return .daemon; | ||
| 21 | if (std.mem.eql(u8, args[1], "a")) return .agent; | ||
| 22 | if (std.mem.eql(u8, args[1], "web")) return .hub; | ||
| 23 | return .client; | ||
| 24 | } | ||
| 25 | |||
| 13 | pub fn main() !u8 { | 26 | pub fn main() !u8 { |
| 14 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 27 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| 15 | defer if (gpa.deinit() == .leak) | 28 | defer if (gpa.deinit() == .leak) |
| @@ -19,23 +32,37 @@ pub fn main() !u8 { | |||
| 19 | const args = try std.process.argsAlloc(alloc); | 32 | const args = try std.process.argsAlloc(alloc); |
| 20 | defer std.process.argsFree(alloc, args); | 33 | defer std.process.argsFree(alloc, args); |
| 21 | 34 | ||
| 22 | if (args.len > 1) { | 35 | // Each named mode is handed a slice whose [0] is the word the user typed |
| 23 | // Each mode is handed a slice whose [0] is the word the user typed | 36 | // and whose [1..] is its own line — the shape every one of these parsers |
| 24 | // and whose [1..] is its own line — the shape every one of these | 37 | // already reads, from back when [0] was the program name. The client |
| 25 | // parsers already reads, from back when [0] was the program name. | 38 | // gets argv whole, because it has no word of its own to skip. |
| 26 | const rest = args[1..]; | 39 | return switch (modeOf(args)) { |
| 27 | if (std.mem.eql(u8, args[1], "d")) return daemon.main(rest); | 40 | .daemon => daemon.main(args[1..]), |
| 28 | if (std.mem.eql(u8, args[1], "a")) return agent.main(rest); | 41 | .agent => agent.main(args[1..]), |
| 29 | if (std.mem.eql(u8, args[1], "web")) return hub.main(rest); | 42 | .hub => hub.main(args[1..]), |
| 30 | // The bridge for daemons of v0.0.1-15 and older, and the only word | 43 | .client => client.main(args), |
| 31 | // that means a mode without naming one. Their upgrade execs the | 44 | }; |
| 32 | // candidate as `<binary> run --resume-fd N` (the argv built in | 45 | } |
| 33 | // server.zig's upgradeExec), so a v15 daemon can only exec its way | 46 | |
| 34 | // into this binary if bare `run` still reaches the daemon. Nothing | 47 | test "modeOf: the three mode words, and nothing else" { |
| 35 | // else is aliased: `mux stop` is a host called "stop". | 48 | try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "run" })); |
| 36 | if (std.mem.eql(u8, args[1], "run")) return daemon.main(args); | 49 | try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" })); |
| 37 | } | 50 | try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" })); |
| 38 | return client.main(args); | 51 | try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"})); |
| 52 | try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box" })); | ||
| 53 | } | ||
| 54 | |||
| 55 | test "modeOf: `run` is a host, not an alias for `d run`" { | ||
| 56 | // There WAS a bare `run` here, as the bridge for a daemon of v0.0.1-15 | ||
| 57 | // or older exec'ing its upgrade candidate as `<binary> run --resume-fd | ||
| 58 | // N`. It is gone because it can never be reached: that daemon runs | ||
| 59 | // `<candidate> --version` first and demands `muxd <version>`, which this | ||
| 60 | // binary does not print, so it refuses the candidate before any exec. | ||
| 61 | // What is left is the rule with no exception — a word that is not a mode | ||
| 62 | // is a transport the user named, and `mux run --resume-fd 5` is refused | ||
| 63 | // by the client's own parser with the page that lists the modes. | ||
| 64 | try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" })); | ||
| 65 | try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" })); | ||
| 39 | } | 66 | } |
| 40 | 67 | ||
| 41 | test { | 68 | test { |
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -796,6 +796,14 @@ fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!Pa | |||
| 796 | return parseArgs(argv, env); | 796 | return parseArgs(argv, env); |
| 797 | } | 797 | } |
| 798 | 798 | ||
| 799 | test "parseArgs: `mux run --resume-fd N` is refused — there is no bare `run`" { | ||
| 800 | // The other half of mux.zig's `modeOf: run is a host` pin, and the half | ||
| 801 | // a user sees: with no alias, the word reaches this parser as a host | ||
| 802 | // spelling and the flag beside it is one no client has. The refusal | ||
| 803 | // prints `usage`, which opens with the four modes. | ||
| 804 | try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" })); | ||
| 805 | } | ||
| 806 | |||
| 799 | test "parseArgs: no arguments means the default local socket" { | 807 | test "parseArgs: no arguments means the default local socket" { |
| 800 | const r = try parse(&.{"mux"}); | 808 | const r = try parse(&.{"mux"}); |
| 801 | try std.testing.expect(r == .attach); | 809 | try std.testing.expect(r == .attach); |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -3583,9 +3583,7 @@ pub const Server = struct { | |||
| 3583 | 3583 | ||
| 3584 | // Build argv: {"mux", "d", "run", "--resume-fd", "<n>"}. The | 3584 | // Build argv: {"mux", "d", "run", "--resume-fd", "<n>"}. The |
| 3585 | // candidate is by definition NEWER than this binary, so it reads the | 3585 | // candidate is by definition NEWER than this binary, so it reads the |
| 3586 | // mode word; a v0.0.1-15 daemon exec'ing a newer one still sends the | 3586 | // mode word. |
| 3587 | // bare `run` its own copy of this line was written with, which is | ||
| 3588 | // why mux.zig keeps that spelling alive. | ||
| 3589 | var fd_buf: [12]u8 = undefined; | 3587 | var fd_buf: [12]u8 = undefined; |
| 3590 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{memfd}) catch return; | 3588 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{memfd}) catch return; |
| 3591 | const argv = [_:null]?[*:0]const u8{ | 3589 | const argv = [_:null]?[*:0]const u8{ |