a73x

e63d80fd

feat: muxd upgrade — the new binary asks

a73x   2026-08-26 15:17

Commit message
feat: muxd upgrade — the new binary asks

The verb runs AS the candidate: it knows its own version and its own path,
resolves /proc/self/exe, and asks the daemon to become it. Status 0 prints
the version and then proves the socket answers again — not with a connect,
which succeeds throughout the handover because the listener fd crosses the
exec, but with an answered frame. Status 1 prints the daemon's reason
verbatim: it is the side that knows which check failed, and paraphrasing
would lose the versions. Silence is a diagnosis, not a timeout — a daemon
older than the frame drops it without a word, so the wait is bounded and
its expiry says `this daemon predates upgrade — stop and run`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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