e63d80fd
feat: muxd upgrade — the new binary asks
a73x 2026-08-26 15:17
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -392,6 +392,7 @@ already on, plus every session recorded in the file. `Ctrl-\` `d` leaves. | |||
| 392 | ```sh | 392 | ```sh |
| 393 | muxd dump [--vt] # debug: print the authoritative grid (what the screen *should* be) | 393 | muxd dump [--vt] # debug: print the authoritative grid (what the screen *should* be) |
| 394 | muxd stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes) | 394 | muxd stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes) |
| 395 | muxd upgrade # exec a newly installed binary in place; every session keeps running | ||
| 395 | make bench # typing-workload byte-ratio measurement | 396 | make bench # typing-workload byte-ratio measurement |
| 396 | ``` | 397 | ``` |
| 397 | 398 | ||
| @@ -424,6 +425,21 @@ kills the daemon with them), though not a reboot. | |||
| 424 | `muxd run` refuses a socket another daemon already owns; there is no | 425 | `muxd run` refuses a socket another daemon already owns; there is no |
| 425 | socket-stealing. | 426 | socket-stealing. |
| 426 | 427 | ||
| 428 | Rolling a new daemon out does **not** mean killing the sessions. Install the | ||
| 429 | new binary and run `muxd upgrade` — the new binary asks the running daemon | ||
| 430 | to become it, and the daemon `execve`s it in place: same pid, same shells, | ||
| 431 | same socket, same ssh-agent sockets. Attached clients see one reconnect and | ||
| 432 | one repaint. It refuses anything that is not strictly newer, naming both | ||
| 433 | versions (`--allow-same-version` exists for the test suite and says so in | ||
| 434 | the usage text), and it refuses on any doubt — it runs the candidate first | ||
| 435 | with `--version` and then over the manifest as a dry run, and a refusal | ||
| 436 | changes nothing at all. If the new binary cannot adopt what it read, it | ||
| 437 | execs the old one back and the old one carries on serving. Scrollback does | ||
| 438 | not survive the handover; the visible grid, the titles, the command marks | ||
| 439 | and the exit codes do. A daemon too old to know the request answers nothing, | ||
| 440 | and `muxd upgrade` says so: `no reply: this daemon predates upgrade — stop | ||
| 441 | and run`. | ||
| 442 | |||
| 427 | Copy and paste work through the session: a mux drag and an application's own | 443 | Copy and paste work through the session: a mux drag and an application's own |
| 428 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, | 444 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, |
| 429 | where nothing else can), a paste arrives bracketed when the application | 445 | where nothing else can), a paste arrives bracketed when the application |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -25,6 +25,8 @@ const usage = | |||
| 25 | \\ muxd endpoint [--sock PATH] (proxy that first announces QUIC port+key) | 25 | \\ muxd endpoint [--sock PATH] (proxy that first announces QUIC port+key) |
| 26 | \\ muxd keygen (write a fresh key to ~/.config/mux/key) | 26 | \\ muxd keygen (write a fresh key to ~/.config/mux/key) |
| 27 | \\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up) | 27 | \\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up) |
| 28 | \\ muxd upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) | ||
| 29 | \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's) | ||
| 28 | \\ muxd --version | 30 | \\ muxd --version |
| 29 | \\ | 31 | \\ |
| 30 | ; | 32 | ; |
| @@ -47,7 +49,7 @@ fn envKey() ?[]const u8 { | |||
| 47 | /// for what the number means and why it lives there. | 49 | /// for what the number means and why it lives there. |
| 48 | const default_quic_idle_ms: u32 = quic.default_idle_ms; | 50 | const default_quic_idle_ms: u32 = quic.default_idle_ms; |
| 49 | 51 | ||
| 50 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, keygen, start, stop }; | 52 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, keygen, start, stop, upgrade }; |
| 51 | 53 | ||
| 52 | /// One row per verb. Adding a subcommand used to mean editing the usage | 54 | /// One row per verb. Adding a subcommand used to mean editing the usage |
| 53 | /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled | 55 | /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled |
| @@ -92,6 +94,7 @@ const specs = [_]Spec{ | |||
| 92 | .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none }, | 94 | .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none }, |
| 93 | .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all }, | 95 | .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all }, |
| 94 | .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all }, | 96 | .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all }, |
| 97 | .{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all }, | ||
| 95 | }; | 98 | }; |
| 96 | 99 | ||
| 97 | comptime { | 100 | comptime { |
| @@ -162,6 +165,9 @@ const Opts = struct { | |||
| 162 | /// rollback leg is the only thing that can prove a daemon survives a | 165 | /// rollback leg is the only thing that can prove a daemon survives a |
| 163 | /// candidate that reads the manifest and then cannot use it. | 166 | /// candidate that reads the manifest and then cannot use it. |
| 164 | resume_fail_at: ?[]const u8 = null, | 167 | resume_fail_at: ?[]const u8 = null, |
| 168 | /// `upgrade`'s one exception to the strictly-newer rule. It exists for | ||
| 169 | /// the e2e leg, which has only one binary to upgrade with. | ||
| 170 | allow_same_version: bool = false, | ||
| 165 | }; | 171 | }; |
| 166 | 172 | ||
| 167 | /// A refusal, carrying whatever `main` needs to print one line about it. | 173 | /// A refusal, carrying whatever `main` needs to print one line about it. |
| @@ -206,6 +212,10 @@ fn parseArgs(args: []const [:0]const u8) ParseResult { | |||
| 206 | o.check = true; | 212 | o.check = true; |
| 207 | continue; | 213 | continue; |
| 208 | } | 214 | } |
| 215 | if (std.mem.eql(u8, a, "--allow-same-version")) { | ||
| 216 | o.allow_same_version = true; | ||
| 217 | continue; | ||
| 218 | } | ||
| 209 | // Every remaining flag takes a value, so the missing-value case is | 219 | // Every remaining flag takes a value, so the missing-value case is |
| 210 | // answered once here rather than at each arm — a flag with nothing | 220 | // answered once here rather than at each arm — a flag with nothing |
| 211 | // after it used to fall through to "unknown argument", which named | 221 | // after it used to fall through to "unknown argument", which named |
| @@ -392,6 +402,7 @@ pub fn main() !u8 { | |||
| 392 | .dump => return dump(alloc, sock_path, o.vt, o.session), | 402 | .dump => return dump(alloc, sock_path, o.vt, o.session), |
| 393 | .stats => return stats(alloc, sock_path), | 403 | .stats => return stats(alloc, sock_path), |
| 394 | .stop => return stopCmd(alloc, sock_path), | 404 | .stop => return stopCmd(alloc, sock_path), |
| 405 | .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), | ||
| 395 | .proxy => { | 406 | .proxy => { |
| 396 | // Attach auto-start: the user asked for a session, not a | 407 | // Attach auto-start: the user asked for a session, not a |
| 397 | // daemon. Same helper and deadline as `muxd start`. Unlike it, a | 408 | // daemon. Same helper and deadline as `muxd start`. Unlike it, a |
| @@ -515,8 +526,8 @@ fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 { | |||
| 515 | } | 526 | } |
| 516 | envp[kept] = rollback_marker ++ "=1"; | 527 | envp[kept] = rollback_marker ++ "=1"; |
| 517 | kept += 1; | 528 | kept += 1; |
| 518 | // The dropped entries leave a tail between the last kept one and the | 529 | // The dropped entries leave a tail of undefined pointers between the |
| 519 | // sentinel; execve reads to the first null. | 530 | // last kept one and the sentinel; execve reads to the first null. |
| 520 | for (envp[kept..]) |*slot| slot.* = null; | 531 | for (envp[kept..]) |*slot| slot.* = null; |
| 521 | return envp.ptr; | 532 | return envp.ptr; |
| 522 | } | 533 | } |
| @@ -813,6 +824,103 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 813 | return 1; | 824 | return 1; |
| 814 | } | 825 | } |
| 815 | 826 | ||
| 827 | /// Ask the daemon on `sock_path` to become THIS binary. The new binary is | ||
| 828 | /// the one that asks: it knows its own version and its own path, and the | ||
| 829 | /// daemon is the one that decides. | ||
| 830 | /// | ||
| 831 | /// Prefixes split as `stop`'s do: `muxd upgrade:` for a refusal or a report | ||
| 832 | /// about this command, plain `muxd:` for the lifecycle verdict. | ||
| 833 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { | ||
| 834 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 835 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 836 | std.debug.print("muxd upgrade: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 837 | return 1; | ||
| 838 | }; | ||
| 839 | |||
| 840 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 841 | std.debug.print("muxd upgrade: nothing listening on {s}\n", .{sock_path}); | ||
| 842 | return 1; | ||
| 843 | }; | ||
| 844 | defer stream.close(); | ||
| 845 | |||
| 846 | var buf: [std.fs.max_path_bytes + 64]u8 = undefined; | ||
| 847 | const payload = proto.encodeUpgradeReq(&buf, .{ | ||
| 848 | .allow_same_version = allow_same, | ||
| 849 | .version = build_options.version, | ||
| 850 | .path = exe, | ||
| 851 | }) catch { | ||
| 852 | std.debug.print("muxd upgrade: cannot name {s} in a request\n", .{exe}); | ||
| 853 | return 1; | ||
| 854 | }; | ||
| 855 | proto.writeFrame(stream.handle, .upgrade_req, payload) catch { | ||
| 856 | std.debug.print("muxd upgrade: {s} closed before the request landed\n", .{sock_path}); | ||
| 857 | return 1; | ||
| 858 | }; | ||
| 859 | |||
| 860 | // Bounded, because a daemon older than this feature drops an unknown | ||
| 861 | // frame without a word: the expiry is a diagnosis, not a timeout. | ||
| 862 | const deadline_ms: i64 = 5000; | ||
| 863 | const t0 = std.time.milliTimestamp(); | ||
| 864 | while (true) { | ||
| 865 | const left = deadline_ms - (std.time.milliTimestamp() - t0); | ||
| 866 | if (left <= 0) break; | ||
| 867 | var pfd = [_]std.posix.pollfd{ | ||
| 868 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 869 | }; | ||
| 870 | if ((std.posix.poll(&pfd, @intCast(left)) catch break) == 0) break; | ||
| 871 | // EOF: an older daemon that drops the connection over a frame it | ||
| 872 | // cannot read reaches the same conclusion as silence does. | ||
| 873 | const frame = (proto.readFrame(alloc, stream.handle) catch break) orelse break; | ||
| 874 | defer frame.deinit(alloc); | ||
| 875 | if (frame.type != .upgrade_reply or frame.payload.len == 0) continue; | ||
| 876 | if (frame.payload[0] != 0) { | ||
| 877 | // The daemon's words, verbatim: it is the side that knows which | ||
| 878 | // check failed, and paraphrasing here would lose the versions. | ||
| 879 | std.debug.print("muxd upgrade: refused: {s}\n", .{frame.payload[1..]}); | ||
| 880 | return 1; | ||
| 881 | } | ||
| 882 | std.debug.print("muxd: upgraded to {s}\n", .{build_options.version}); | ||
| 883 | return confirmServing(alloc, sock_path); | ||
| 884 | } | ||
| 885 | std.debug.print( | ||
| 886 | "muxd upgrade: no reply: this daemon predates upgrade — stop and run\n", | ||
| 887 | .{}, | ||
| 888 | ); | ||
| 889 | return 1; | ||
| 890 | } | ||
| 891 | |||
| 892 | /// The socket answered, and the answer came from the new image. | ||
| 893 | fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { | ||
| 894 | // Not `spawn.probe`: the listener fd crosses the exec, so a connect | ||
| 895 | // succeeds throughout the handover — it is served out of the backlog by | ||
| 896 | // whichever image accepts it. Only an ANSWERED frame says the new one | ||
| 897 | // is pumping. | ||
| 898 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 899 | std.debug.print("muxd upgrade: {s} stopped answering after the exec\n", .{sock_path}); | ||
| 900 | return 1; | ||
| 901 | }; | ||
| 902 | defer stream.close(); | ||
| 903 | proto.writeFrame(stream.handle, .stats_req, "") catch return 1; | ||
| 904 | |||
| 905 | const deadline_ms: i64 = 5000; | ||
| 906 | var pfd = [_]std.posix.pollfd{ | ||
| 907 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 908 | }; | ||
| 909 | if ((std.posix.poll(&pfd, deadline_ms) catch 0) > 0) { | ||
| 910 | if (proto.readFrame(alloc, stream.handle) catch null) |frame| { | ||
| 911 | defer frame.deinit(alloc); | ||
| 912 | if (frame.type == .stats_reply) return 0; | ||
| 913 | } | ||
| 914 | } | ||
| 915 | const secs = @divTrunc(deadline_ms, 1000); | ||
| 916 | var hint: [log_hint_len]u8 = undefined; | ||
| 917 | std.debug.print( | ||
| 918 | "muxd upgrade: exec'd, but {s} has not answered in {d}s{s}\n", | ||
| 919 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 920 | ); | ||
| 921 | return 1; | ||
| 922 | } | ||
| 923 | |||
| 816 | const log_hint_len = std.fs.max_path_bytes + 64; | 924 | const log_hint_len = std.fs.max_path_bytes + 64; |
| 817 | 925 | ||
| 818 | /// The "where the rest of the story is" clause, or "" when there is no | 926 | /// The "where the rest of the story is" clause, or "" when there is no |
| @@ -1432,6 +1540,20 @@ test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" | |||
| 1432 | try std.testing.expect(rollbackKeepsEnv("MUX_RESUME_FAIL_AT_NOT=1")); | 1540 | try std.testing.expect(rollbackKeepsEnv("MUX_RESUME_FAIL_AT_NOT=1")); |
| 1433 | } | 1541 | } |
| 1434 | 1542 | ||
| 1543 | test "parseArgs: upgrade is a command, and same-version is a flag it takes" { | ||
| 1544 | const r = parse(&.{ "muxd", "upgrade" }); | ||
| 1545 | try std.testing.expect(r == .ok); | ||
| 1546 | try std.testing.expect(r.ok.cmd == .upgrade); | ||
| 1547 | // Off unless asked: the skew rule is strictly-newer, and an operator who | ||
| 1548 | // did not name the exception must not get it. | ||
| 1549 | try std.testing.expect(!r.ok.allow_same_version); | ||
| 1550 | |||
| 1551 | const s = parse(&.{ "muxd", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" }); | ||
| 1552 | try std.testing.expect(s.ok.cmd == .upgrade); | ||
| 1553 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1554 | try std.testing.expect(s.ok.allow_same_version); | ||
| 1555 | } | ||
| 1556 | |||
| 1435 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { | 1557 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { |
| 1436 | const alloc = std.testing.allocator; | 1558 | const alloc = std.testing.allocator; |
| 1437 | 1559 | ||