a73x

5c9897e0

feat: mux d upgrade HOST pushes this image over ssh and upgrades there

a73x   2026-09-01 15:00

Commit message
feat: mux d upgrade HOST pushes this image over ssh and upgrades there

One binary means the machine you build on already holds the image every
box needs, so the remote spelling of the upgrade verb streams
/proc/self/exe rather than fetching a release. Three foreground sshs:
a preflight whose LINE COUNT is the verdict (uname -m && command -v mux
&& mux d endpoint, the bare read verb — 1 line no mux, 2 install-only,
3 full flow, with the arch checked before any byte moves), the image
streamed into an atomic rename over the installed mux so a dropped
connection can never truncate a binary, then that box's own
'mux d upgrade' — version rule, manifest and serving check stay
daemon-side. No daemon on the box is install-only success; no mux at
all is a refusal, because a push replaces an install rather than
inventing one. --sock with HOST is refused loudly instead of silently
honoring one and dropping the other.

The usage block grew a line for [HOST], so the boot leg's absolute
refusal bound moves 15 -> 16, re-derived the way its comment derives
it. The e2e 16_push group fakes the remote behind the ssh shim and
asserts against the fake box's own files and /proc: replacement by
marker bytes, same daemon pid, exe link naming the installed path.

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

CLAUDE.md
Old New
@@ -271,7 +271,14 @@ own. Test fixtures in `test/`:
271 - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid, 271 - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid,
272 same children, same fds — so `waitpid`, the pid-named shim and agent dirs, 272 same children, same fds — so `waitpid`, the pid-named shim and agent dirs,
273 and the socket path all survive untouched, and `Server.deinit` never runs 273 and the socket path all survive untouched, and `Server.deinit` never runs
274 on that path. The manifest memfd carries only 274 on that path. `mux d upgrade HOST` is the remote spelling: a preflight ssh
275 whose LINE COUNT is the verdict (`uname -m && command -v mux && mux d
276 endpoint`, the bare read verb), the running image streamed into an atomic
277 rename over the installed mux, then that box's own `mux d upgrade` — so
278 the version rule, manifest and serving check stay daemon-side and are
279 never reimplemented client-side. No daemon on the box is install-only
280 success; no mux on the box is a refusal, because a push replaces an
281 install rather than inventing one. The manifest memfd carries only
275 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the 282 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
276 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback, 283 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
277 clients, delta trackers, agent channels and per-connection QUIC state are 284 clients, delta trackers, agent channels and per-connection QUIC state are
README.md
Old New
@@ -521,6 +521,7 @@ mux d start [-d] # the daemon: in this shell, or -d to fork one and wait for
521 mux d dump [--vt] # debug: print the authoritative grid (what the screen *should* be) 521 mux d dump [--vt] # debug: print the authoritative grid (what the screen *should* be)
522 mux d stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes) 522 mux d stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes)
523 mux d upgrade # exec a newly installed binary in place; every session keeps running 523 mux d upgrade # exec a newly installed binary in place; every session keeps running
524 mux d upgrade HOST # push THIS binary onto HOST over ssh, then upgrade its daemon the same way
524 make bench # typing-workload byte-ratio measurement 525 make bench # typing-workload byte-ratio measurement
525 ``` 526 ```
526 527
@@ -572,6 +573,16 @@ and run`. A session in the middle of being ended (`Ctrl-\` `x`, up to half a
572 second while its shell is being hung up) refuses the upgrade with `session 573 second while its shell is being hung up) refuses the upgrade with `session
573 ending, retry` rather than carrying a shell with no master across the exec. 574 ending, retry` rather than carrying a shell with no master across the exec.
574 575
576 The remote spelling is `mux d upgrade HOST`: one binary means the machine
577 you build on already holds the image every box needs, so this pushes the
578 running binary itself over ssh — it checks the arches match and finds the
579 installed `mux` first (`command -v`, with `~/.local/bin` as the fallback),
580 streams the image into a temp file and renames it over the install so a
581 dropped connection can never truncate a binary, then runs that box's own
582 `mux d upgrade`, which is everything above, there. A box with no daemon
583 running just gets the install (exit 0, and it says so); a box with no mux
584 at all is refused — a push replaces an install, it does not invent one.
585
575 Coming from v0.0.1-15 or older is the one upgrade `mux d upgrade` cannot 586 Coming from v0.0.1-15 or older is the one upgrade `mux d upgrade` cannot
576 do: that daemon probes a candidate for `muxd <version>` and this binary 587 do: that daemon probes a candidate for `muxd <version>` and this binary
577 prints `mux <version>`, so it refuses. `mux d stop` then `mux d start -d`, 588 prints `mux <version>`, so it refuses. `mux d stop` then `mux d start -d`,
docs/decisions.md
Old New
@@ -7748,3 +7748,36 @@ Landing it also fixed two recorded heal defects — a heal now keeps the
7748 survivor's weights and the container's orientation, because nothing 7748 survivor's weights and the container's orientation, because nothing
7749 collapses so nothing re-wraps (measured in the e2e heal leg's sidecar 7749 collapses so nothing re-wraps (measured in the e2e heal leg's sidecar
7750 pins). 7750 pins).
7751
7752 ## 2026-09-01 — remote upgrade pushes the running image; the suite gets a hermetic default socket
7753
7754 `mux d upgrade HOST` is the remote spelling of the local verb, and the
7755 design leans on ONE binary: the machine you build on already holds the
7756 image every box needs, so the push streams `/proc/self/exe` over ssh —
7757 no release fetch, no remote network, dev builds included. Chosen shapes:
7758 - **Preflight by line count.** One ssh runs `uname -m && command -v mux
7759 && mux d endpoint` (the bare read verb — a preflight that started
7760 daemons would repeat the poll bug of 2026-08). `&&` stops at the first
7761 answerless step, so 1 line = no mux (refused; a push replaces an
7762 install, it does not invent one), 2 = install-only (exit 0, said so),
7763 3 = full flow. The arch line is checked before any byte moves because
7764 the streamed image is this machine's own.
7765 - **Atomic rename, remote trigger.** `cat > mux.new && chmod && mv` so a
7766 dropped stream can never truncate the installed binary; then the box's
7767 own `mux d upgrade` runs, keeping the version rule, manifest and
7768 serving check daemon-side rather than reimplemented client-side.
7769 - **Rejected:** a release-tarball pull (needs remote egress, cannot ship
7770 a dev build), an `--all` sweep (a shell loop away; per-host failure
7771 reporting not worth designing yet), cross-arch push (refused loudly
7772 instead).
7773
7774 Found by the leg's RED run, fixed in e2e_lib: the suite isolated
7775 XDG_CONFIG/STATE/CACHE but not XDG_RUNTIME_DIR, so a leg with a bug that
7776 dialed the DEFAULT socket reached the developer's live daemon at
7777 /run/user/*/muxd.sock — the not-yet-wired `d upgrade HOST` sent it a real
7778 upgrade request, and only the version rule refused the exec. The lib now
7779 exports a scratch XDG_RUNTIME_DIR, so a stray default dial reads "nothing
7780 listening" instead of someone's real daemon. The usage block grew a line
7781 with `[HOST]`, so the boot leg's absolute refusal bound moved 15 -> 16,
7782 re-derived the way its comment derives it (above complaint-plus-usage,
7783 below the ~20-line Debug panic floor).
src/cli/main.zig
Old New
@@ -5,6 +5,7 @@
5 // `/bin/sh` is the executable fallback when `$SHELL` is unset. 5 // `/bin/sh` is the executable fallback when `$SHELL` is unset.
6 6
7 const std = @import("std"); 7 const std = @import("std");
8 const builtin = @import("builtin");
8 const Server = @import("daemon").Server; 9 const Server = @import("daemon").Server;
9 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
10 const proxy = @import("proxy"); 11 const proxy = @import("proxy");
@@ -30,7 +31,8 @@ const usage =
30 \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket) 31 \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket)
31 \\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy) 32 \\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy)
32 \\ mux d keygen (write a fresh key to ~/.config/mux/key) 33 \\ mux d keygen (write a fresh key to ~/.config/mux/key)
33 \\ mux d upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) 34 \\ mux d upgrade [HOST] [--sock PATH] (exec THIS binary over the daemon; sessions
35 \\ live. HOST: push this image there over ssh, then upgrade its daemon)
34 \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's) 36 \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's)
35 \\ mux d --version 37 \\ mux d --version
36 \\ mux d --help 38 \\ mux d --help
@@ -143,6 +145,16 @@ const DaemonArguments = struct {
143 /// direct cold attach uses this flag; background polls omit it and remain 145 /// direct cold attach uses this flag; background polls omit it and remain
144 /// read-only. 146 /// read-only.
145 start: bool = false, 147 start: bool = false,
148 /// `upgrade`'s one positional: the ssh host to push this image to and
149 /// upgrade there. Underscored out of generated flags; every other verb
150 /// leaves the hook cold so a bare word stays a syntax error on them.
151 _host: ?[]const u8 = null,
152
153 pub fn positional(self: *DaemonArguments, word: []const u8) bool {
154 if (self._cmd != .upgrade or self._host != null) return false;
155 self._host = word;
156 return true;
157 }
146 }; 158 };
147 159
148 // These upgrade-only flags are generated by the old daemon and intentionally 160 // These upgrade-only flags are generated by the old daemon and intentionally
@@ -164,6 +176,11 @@ const UsageResponse = union(enum) {
164 /// The flag whose value its own type would not hold. 176 /// The flag whose value its own type would not hold.
165 bad_value: []const u8, 177 bad_value: []const u8,
166 key_without_quic, 178 key_without_quic,
179 /// `upgrade HOST --sock PATH`: the socket that matters on a remote
180 /// upgrade is the remote box's own default, and a --sock honored
181 /// locally while HOST is honored remotely would leave the user
182 /// believing both.
183 sock_with_host,
167 }; 184 };
168 185
169 const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse }; 186 const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse };
@@ -211,6 +228,8 @@ fn parseArgs(args: []const [:0]const u8) DaemonInvocation {
211 // `run` may resolve `MUX_KEY_FILE` or the default key path. 228 // `run` may resolve `MUX_KEY_FILE` or the default key path.
212 if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic }; 229 if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic };
213 230
231 if (o._host != null and o.sock != null) return .{ .usage = .sock_with_host };
232
214 return .{ .command = o }; 233 return .{ .command = o };
215 } 234 }
216 235
@@ -231,6 +250,10 @@ fn usageExit(u: UsageResponse) u8 {
231 "mux d: --key without --quic has nothing to listen on; name both or neither\n", 250 "mux d: --key without --quic has nothing to listen on; name both or neither\n",
232 .{}, 251 .{},
233 ), 252 ),
253 .sock_with_host => std.debug.print(
254 "mux d: upgrade HOST uses that box's own default socket; --sock only names a local one\n",
255 .{},
256 ),
234 } 257 }
235 return usageCode(u); 258 return usageCode(u);
236 } 259 }
@@ -257,7 +280,10 @@ pub fn main(args: []const [:0]const u8) !u8 {
257 // Resolve a socket path only for commands that use one. Version, help, and 280 // Resolve a socket path only for commands that use one. Version, help, and
258 // key generation must not depend on runtime-directory state. Resume mode 281 // key generation must not depend on runtime-directory state. Resume mode
259 // receives its already-bound path through the manifest. 282 // receives its already-bound path through the manifest.
260 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null; 283 // A remote upgrade names a HOST, and the socket that matters is that
284 // box's own default; resolving a local one here would refuse the verb on
285 // a machine with no XDG_RUNTIME_DIR for no reason it could name.
286 const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null and o._host == null;
261 const sock_path = if (o.sock) |s| 287 const sock_path = if (o.sock) |s|
262 try alloc.dupe(u8, s) 288 try alloc.dupe(u8, s)
263 else if (!uses_socket) 289 else if (!uses_socket)
@@ -283,7 +309,10 @@ pub fn main(args: []const [:0]const u8) !u8 {
283 .dump => return dump(alloc, sock_path, o.vt, if (o.session) |n| n.name else ""), 309 .dump => return dump(alloc, sock_path, o.vt, if (o.session) |n| n.name else ""),
284 .stats => return stats(alloc, sock_path), 310 .stats => return stats(alloc, sock_path),
285 .stop => return stopCmd(alloc, sock_path), 311 .stop => return stopCmd(alloc, sock_path),
286 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), 312 .upgrade => return if (o._host) |h|
313 remoteUpgradeCmd(alloc, h, o.allow_same_version)
314 else
315 upgradeCmd(alloc, sock_path, o.allow_same_version),
287 // Proxy only pumps an existing daemon connection; it never starts a 316 // Proxy only pumps an existing daemon connection; it never starts a
288 // daemon. `proxy.run` reports the socket path when connection fails. 317 // daemon. `proxy.run` reports the socket path when connection fails.
289 .proxy => return proxy.run(sock_path), 318 .proxy => return proxy.run(sock_path),
@@ -698,6 +727,173 @@ fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 {
698 return 0; 727 return 0;
699 } 728 }
700 729
730 /// What the remote-upgrade preflight learned. One ssh run answers everything:
731 /// `uname -m && command -v mux && mux d endpoint` — the bare endpoint verb,
732 /// which never starts a daemon — and the LINE COUNT is the diagnosis, because
733 /// `&&` stops at the first answerless step.
734 const Preflight = union(enum) {
735 /// The ssh ran but stdout was empty: no shell spoke, nothing to trust.
736 no_answer,
737 /// `command -v mux` found nothing. Nothing to overwrite; inventing an
738 /// install location for someone else's box is not this verb's call.
739 no_mux,
740 /// The remote's `uname -m`, verbatim, for a refusal line that shows both
741 /// spellings — we stream THIS machine's image, so the words must match.
742 bad_arch: []const u8,
743 /// mux is installed at `path`; `daemon_up` says whether the endpoint verb
744 /// answered, i.e. whether there is a daemon to trigger after the push.
745 ready: struct { path: []const u8, daemon_up: bool },
746 };
747
748 /// `mux d upgrade HOST`: put THIS image on `host` and upgrade the daemon
749 /// there — the remote spelling of the local verb, in the same three moves a
750 /// hand upgrade makes. Preflight (one ssh, read), push (stream the image
751 /// into an atomic rename over the installed mux), trigger (run the freshly
752 /// installed binary as `d upgrade`, which makes IT the candidate and leaves
753 /// the version rule, the manifest and the serving check to the machinery
754 /// that already owns them). All three sshs run in the foreground with the
755 /// user's own tty, like the entry dial: this is a dial the user asked for,
756 /// and ssh's prompts are part of it.
757 fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool) !u8 {
758 // A refused write on a dead ssh must come back as BrokenPipe from the
759 // push loop below, not as SIGPIPE ending this process mid-report.
760 proxy.ignoreSigpipe();
761
762 const pf_argv = try handoff.upgradePreflightArgv(alloc, host);
763 defer handoff.freeArgv(alloc, pf_argv);
764 var pf = std.process.Child.init(pf_argv, alloc);
765 // stdin is EOF up front: a daemon's `endpoint` answer turns into a
766 // stdio proxy that pumps until its stdin closes, and the preflight only
767 // wants the announce line, not the pump.
768 pf.stdin_behavior = .Ignore;
769 pf.stdout_behavior = .Pipe;
770 // ssh's prompts and complaints go straight to the user; there is no
771 // wall on screen to protect during a `d` verb.
772 pf.stderr_behavior = .Inherit;
773 try pf.spawn();
774 // Drain to EOF even past the cap — a child blocked on a full pipe never
775 // exits, and wait() below would hold this process with it.
776 var pf_buf: [4096]u8 = undefined;
777 var pf_len: usize = 0;
778 while (true) {
779 var chunk: [1024]u8 = undefined;
780 const n = std.posix.read(pf.stdout.?.handle, &chunk) catch break;
781 if (n == 0) break;
782 const keep = @min(n, pf_buf.len - pf_len);
783 @memcpy(pf_buf[pf_len..][0..keep], chunk[0..keep]);
784 pf_len += keep;
785 }
786 const pf_term = try pf.wait();
787 // 255 is ssh's own exit code, distinct from any remote command's: the
788 // dial itself failed, and ssh has already said why on stderr.
789 if (pf_term == .Exited and pf_term.Exited == 255) {
790 std.debug.print("mux d upgrade: ssh to {s} failed\n", .{host});
791 return 1;
792 }
793
794 const local_arch = @tagName(builtin.cpu.arch);
795 const ready = switch (parsePreflight(pf_buf[0..pf_len], local_arch)) {
796 .no_answer => {
797 std.debug.print(
798 "mux d upgrade: {s}'s preflight answered nothing; not a box this can read\n",
799 .{host},
800 );
801 return 1;
802 },
803 .no_mux => {
804 std.debug.print(
805 "mux d upgrade: no mux on {s}'s PATH; install one there first — " ++
806 "a push only replaces an install, it does not invent one\n",
807 .{host},
808 );
809 return 1;
810 },
811 .bad_arch => |arch| {
812 std.debug.print(
813 "mux d upgrade: {s} is {s} and this image is {s}; refusing to push a binary that cannot run there\n",
814 .{ host, arch, local_arch },
815 );
816 return 1;
817 },
818 .ready => |r| r,
819 };
820
821 const push_argv = handoff.upgradePushArgv(alloc, host, ready.path) catch |e| switch (e) {
822 error.BadTargetPath => {
823 std.debug.print(
824 "mux d upgrade: {s} answered a mux path with a quote in it ({s}); not a box this will shell at\n",
825 .{ host, ready.path },
826 );
827 return 1;
828 },
829 error.OutOfMemory => return error.OutOfMemory,
830 };
831 defer handoff.freeArgv(alloc, push_argv);
832 var push = std.process.Child.init(push_argv, alloc);
833 push.stdin_behavior = .Pipe;
834 push.stdout_behavior = .Inherit;
835 push.stderr_behavior = .Inherit;
836 try push.spawn();
837 // /proc/self/exe rather than a saved path: the bytes streamed are the
838 // image RUNNING here, the only thing "push this binary" can honestly
839 // mean, even if the file it was started from has since been replaced.
840 const streamed: bool = blk: {
841 var img = std.fs.openFileAbsolute("/proc/self/exe", .{}) catch break :blk false;
842 defer img.close();
843 var buf: [64 * 1024]u8 = undefined;
844 while (true) {
845 const n = std.posix.read(img.handle, &buf) catch break :blk false;
846 if (n == 0) break :blk true;
847 var off: usize = 0;
848 while (off < n) {
849 off += std.posix.write(push.stdin.?.handle, buf[off..n]) catch break :blk false;
850 }
851 }
852 };
853 push.stdin.?.close();
854 push.stdin = null;
855 const push_term = try push.wait();
856 if (!streamed or push_term != .Exited or push_term.Exited != 0) {
857 std.debug.print("mux d upgrade: the push to {s} did not land; nothing was replaced\n", .{host});
858 return 1;
859 }
860
861 if (!ready.daemon_up) {
862 std.debug.print(
863 "mux d upgrade: installed {s} at {s}:{s}; no daemon running there to upgrade\n",
864 .{ build_options.version, host, ready.path },
865 );
866 return 0;
867 }
868
869 const trig_argv = try handoff.upgradeTriggerArgv(alloc, host, allow_same);
870 defer handoff.freeArgv(alloc, trig_argv);
871 var trig = std.process.Child.init(trig_argv, alloc);
872 // Fully inherited: the remote `mux d upgrade` reports in its own words —
873 // the daemon's refusal reasons included — and those words are the report.
874 trig.stdin_behavior = .Inherit;
875 trig.stdout_behavior = .Inherit;
876 trig.stderr_behavior = .Inherit;
877 try trig.spawn();
878 const trig_term = try trig.wait();
879 return switch (trig_term) {
880 .Exited => |code| code,
881 else => 1,
882 };
883 }
884
885 /// Slices into `out` — the caller keeps the buffer alive as long as the result.
886 fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight {
887 var lines = std.mem.splitScalar(u8, out, '\n');
888 const arch = lines.next() orelse "";
889 if (arch.len == 0) return .no_answer;
890 if (!std.mem.eql(u8, arch, want_arch)) return .{ .bad_arch = arch };
891 const path = lines.next() orelse "";
892 if (path.len == 0) return .no_mux;
893 const announce = lines.next() orelse "";
894 return .{ .ready = .{ .path = path, .daemon_up = announce.len != 0 } };
895 }
896
701 /// Ask the daemon on `sock_path` to replace itself with the current binary. The 897 /// Ask the daemon on `sock_path` to replace itself with the current binary. The
702 /// candidate supplies its version and path; the running daemon decides whether 898 /// candidate supplies its version and path; the running daemon decides whether
703 /// the upgrade is allowed. 899 /// the upgrade is allowed.
@@ -1241,6 +1437,59 @@ test "parseArgs: subcommands and their existing flags" {
1241 try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg); 1437 try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg);
1242 } 1438 }
1243 1439
1440 test "parsePreflight: the line count is the diagnosis" {
1441 // Three lines: arch, the installed path, an endpoint announce — a daemon
1442 // is up and the full push-then-upgrade flow applies.
1443 const up = parsePreflight("x86_64\n/home/u/.local/bin/mux\nquic 1.2.3.4:1 k\n", "x86_64");
1444 try std.testing.expectEqualStrings("/home/u/.local/bin/mux", up.ready.path);
1445 try std.testing.expect(up.ready.daemon_up);
1446
1447 // Two lines: mux is installed but `mux d endpoint` answered nothing —
1448 // push the image, skip the trigger.
1449 const idle = parsePreflight("x86_64\n/usr/local/bin/mux\n", "x86_64");
1450 try std.testing.expectEqualStrings("/usr/local/bin/mux", idle.ready.path);
1451 try std.testing.expect(!idle.ready.daemon_up);
1452
1453 // One line: `command -v mux` found nothing — there is nothing to
1454 // overwrite, and inventing an install location is not this verb's call.
1455 try std.testing.expect(parsePreflight("x86_64\n", "x86_64") == .no_mux);
1456
1457 // The remote's word comes back for the refusal line, so the user reads
1458 // both spellings rather than a bare "mismatch".
1459 const arm = parsePreflight("aarch64\n/usr/bin/mux\n", "x86_64");
1460 try std.testing.expectEqualStrings("aarch64", arm.bad_arch);
1461
1462 // Nothing at all is its own answer: the ssh ran but no shell spoke.
1463 try std.testing.expect(parsePreflight("", "x86_64") == .no_answer);
1464 try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer);
1465 }
1466
1467 test "parseArgs: upgrade takes one HOST word, and only upgrade does" {
1468 const r = parse(&.{ "d", "upgrade", "box" });
1469 try std.testing.expect(r == .command);
1470 try std.testing.expect(r.command._cmd == .upgrade);
1471 try std.testing.expectEqualStrings("box", r.command._host.?);
1472
1473 // The flag still rides beside the word, in either order.
1474 const f = parse(&.{ "d", "upgrade", "--allow-same-version", "box" });
1475 try std.testing.expectEqualStrings("box", f.command._host.?);
1476 try std.testing.expect(f.command.allow_same_version);
1477
1478 // No word means the local daemon, exactly as before.
1479 try std.testing.expect(parse(&.{ "d", "upgrade" }).command._host == null);
1480
1481 // A second word is a mistake, not a second host.
1482 try std.testing.expect(parse(&.{ "d", "upgrade", "box", "box2" }).usage == .unknown_arg);
1483
1484 // Every other verb still refuses a bare word: a host on `stop` would be
1485 // a remote stop nobody designed.
1486 try std.testing.expect(parse(&.{ "d", "stop", "box" }).usage == .unknown_arg);
1487
1488 // --sock names a LOCAL socket and HOST names another box; honoring one
1489 // and dropping the other silently would leave the user believing both.
1490 try std.testing.expect(parse(&.{ "d", "upgrade", "box", "--sock", "/x" }).usage == .sock_with_host);
1491 }
1492
1244 test "parse: dump --session rides into the payload" { 1493 test "parse: dump --session rides into the payload" {
1245 const d = parse(&.{ "d", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); 1494 const d = parse(&.{ "d", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
1246 try std.testing.expect(d == .command); 1495 try std.testing.expect(d == .command);
src/client/handoff.zig
Old New
@@ -240,6 +240,12 @@ pub fn freeArgv(alloc: std.mem.Allocator, argv: []const []const u8) void {
240 alloc.free(argv); 240 alloc.free(argv);
241 } 241 }
242 242
243 /// The one spelling of the ~/.local/bin fallback. `sshArgv` uses it as an
244 /// assignment prefix on a single command; the upgrade words use it as a
245 /// statement (`;`-terminated), because an assignment before `a && b` binds
246 /// to `a` alone and the commands after the `&&`s would search the bare PATH.
247 const local_bin_append = "PATH=\"$PATH:$HOME/.local/bin\"";
248
243 /// ONE owner for the ssh line's shape. 249 /// ONE owner for the ssh line's shape.
244 fn sshArgv( 250 fn sshArgv(
245 alloc: std.mem.Allocator, 251 alloc: std.mem.Allocator,
@@ -264,8 +270,21 @@ fn sshArgv(
264 // round. sshd runs that shell non-login, so it never sources the profile 270 // round. sshd runs that shell non-login, so it never sources the profile
265 // putting ~/.local/bin on PATH. APPENDED: a fallback place to look, never a 271 // putting ~/.local/bin on PATH. APPENDED: a fallback place to look, never a
266 // shadow over whatever `mux` the remote PATH already resolves. 272 // shadow over whatever `mux` the remote PATH already resolves.
267 const word = try std.fmt.allocPrint(alloc, "PATH=\"$PATH:$HOME/.local/bin\" {s}", .{remote}); 273 const word = try std.fmt.allocPrint(alloc, local_bin_append ++ " {s}", .{remote});
268 defer alloc.free(word); 274 defer alloc.free(word);
275 return sshArgvWord(alloc, host, batch_opt, word);
276 }
277
278 /// The assembly half of `sshArgv`: `word` is the finished remote word,
279 /// PATH fallback already spelled. Split out so the upgrade words — compound
280 /// statements whose fallback must be a statement, not an assignment prefix —
281 /// share the argv shape without a second spelling of it.
282 fn sshArgvWord(
283 alloc: std.mem.Allocator,
284 host: []const u8,
285 batch_opt: []const []const u8,
286 word: []const u8,
287 ) ![]const []const u8 {
269 var argv: [7][]const u8 = undefined; 288 var argv: [7][]const u8 = undefined;
270 argv[0] = "ssh"; 289 argv[0] = "ssh";
271 @memcpy(argv[1 .. 1 + batch_opt.len], batch_opt); 290 @memcpy(argv[1 .. 1 + batch_opt.len], batch_opt);
@@ -289,6 +308,53 @@ pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recip
289 }; 308 };
290 } 309 }
291 310
311 /// The remote-upgrade preflight: one ssh whose LINE COUNT is the verdict.
312 /// `&&` stops at the first answerless step, so one line means no mux on the
313 /// remote PATH, two means mux with no daemon, three means a daemon is up —
314 /// and line one is `uname -m`, checked before any bytes move, because the
315 /// push streams the local machine's own image. The bare endpoint verb, never
316 /// `--start`: a read must not start a daemon (the invariant `recipeFor`'s
317 /// polls live by), and an upgrade preflight is a read.
318 pub fn upgradePreflightArgv(alloc: std.mem.Allocator, host: []const u8) ![]const []const u8 {
319 const word = local_bin_append ++ "; uname -m && command -v mux && mux d endpoint";
320 return sshArgvWord(alloc, host, &.{}, word);
321 }
322
323 /// The push: land the streamed image beside the installed mux, then rename
324 /// over it. Atomic on purpose — a connection dropped mid-stream must never
325 /// leave a truncated binary at the installed path, and the running daemon
326 /// keeps its old inode undisturbed. `target` came off the remote's own
327 /// `command -v`; a single quote in it means something is lying, and this
328 /// refuses rather than escapes.
329 pub fn upgradePushArgv(
330 alloc: std.mem.Allocator,
331 host: []const u8,
332 target: []const u8,
333 ) error{ BadTargetPath, OutOfMemory }![]const []const u8 {
334 if (std.mem.indexOfScalar(u8, target, '\'') != null) return error.BadTargetPath;
335 const word = try std.fmt.allocPrint(
336 alloc,
337 local_bin_append ++ "; cat > '{s}.new' && chmod 755 '{s}.new' && mv '{s}.new' '{s}'",
338 .{ target, target, target, target },
339 );
340 defer alloc.free(word);
341 return sshArgvWord(alloc, host, &.{}, word);
342 }
343
344 /// The trigger: the freshly pushed binary is now the installed mux, and
345 /// running IT as `d upgrade` makes it the candidate — the daemon-side version
346 /// rule, manifest and serving check need nothing new from here.
347 pub fn upgradeTriggerArgv(
348 alloc: std.mem.Allocator,
349 host: []const u8,
350 allow_same: bool,
351 ) ![]const []const u8 {
352 return sshArgv(alloc, host, false, if (allow_same)
353 "mux d upgrade --allow-same-version"
354 else
355 "mux d upgrade");
356 }
357
292 /// The announce line for `ep` at `path`: mode 0600, parents created, immediate 358 /// The announce line for `ep` at `path`: mode 0600, parents created, immediate
293 /// parent tightened to 0700 — the key travels in this file. OVERWRITES, unlike 359 /// parent tightened to 0700 — the key travels in this file. OVERWRITES, unlike
294 /// `xdg.writeNewKey`: everything here is re-derivable from one ssh. 360 /// `xdg.writeNewKey`: everything here is re-derivable from one ssh.
@@ -721,6 +787,65 @@ test "recipeFor: the asked command is the ssh line with `mux d endpoint --start`
721 }, quiet.asked_argv); 787 }, quiet.asked_argv);
722 } 788 }
723 789
790 test "upgradePreflightArgv: one run, three answers, and the line count is the verdict" {
791 const argv = try upgradePreflightArgv(std.testing.allocator, "user@box");
792 defer freeArgv(std.testing.allocator, argv);
793 // STATEMENT-form PATH, not the assignment-prefix `recipeFor` uses: an
794 // assignment before `uname` would bind to `uname` alone, and the two
795 // commands after the `&&`s — the ones that actually need ~/.local/bin —
796 // would search the bare non-login PATH. The bare endpoint verb, never
797 // `--start`: a preflight that started daemons would undo every remote
798 // `mux d stop` the moment someone upgraded.
799 try expectArgv(&.{
800 "ssh",
801 "user@box",
802 "PATH=\"$PATH:$HOME/.local/bin\"; uname -m && command -v mux && mux d endpoint",
803 }, argv);
804 }
805
806 test "upgradePushArgv: the image lands beside the target and renames over it" {
807 const argv = try upgradePushArgv(std.testing.allocator, "user@box", "/home/u/.local/bin/mux");
808 defer freeArgv(std.testing.allocator, argv);
809 // `.new` then `mv`: the rename is atomic, so a connection dropped
810 // mid-stream never leaves a truncated binary at the installed path, and
811 // a running daemon keeps its old inode undisturbed.
812 try expectArgv(&.{
813 "ssh",
814 "user@box",
815 "PATH=\"$PATH:$HOME/.local/bin\"; cat > '/home/u/.local/bin/mux.new'" ++
816 " && chmod 755 '/home/u/.local/bin/mux.new'" ++
817 " && mv '/home/u/.local/bin/mux.new' '/home/u/.local/bin/mux'",
818 }, argv);
819 }
820
821 test "upgradePushArgv: a quote in the target is refused, not escaped" {
822 // The path came off the remote's own `command -v` — a quote in it means
823 // something is lying, and quoting games on a remote shell are not a
824 // fight worth winning.
825 try std.testing.expectError(
826 error.BadTargetPath,
827 upgradePushArgv(std.testing.allocator, "box", "/tmp/it's/mux"),
828 );
829 }
830
831 test "upgradeTriggerArgv: the freshly installed mux is the candidate, flag and all" {
832 const bare = try upgradeTriggerArgv(std.testing.allocator, "user@box", false);
833 defer freeArgv(std.testing.allocator, bare);
834 try expectArgv(&.{
835 "ssh",
836 "user@box",
837 "PATH=\"$PATH:$HOME/.local/bin\" mux d upgrade",
838 }, bare);
839
840 const same = try upgradeTriggerArgv(std.testing.allocator, "user@box", true);
841 defer freeArgv(std.testing.allocator, same);
842 try expectArgv(&.{
843 "ssh",
844 "user@box",
845 "PATH=\"$PATH:$HOME/.local/bin\" mux d upgrade --allow-same-version",
846 }, same);
847 }
848
724 test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { 849 test "recipeFor: a batch recipe cannot prompt, an interactive one still can" {
725 const alloc = std.testing.allocator; 850 const alloc = std.testing.allocator;
726 // The poller's and `mux hosts`'s recipe. Two hosts, because the flag 851 // The poller's and `mux hosts`'s recipe. Two hosts, because the flag
test/e2e.sh
Old New
@@ -105,7 +105,7 @@ command -v python3 > /dev/null 2>&1 || {
105 E2E_DIR=$(dirname "$0") 105 E2E_DIR=$(dirname "$0")
106 . "$E2E_DIR/e2e_lib.sh" 106 . "$E2E_DIR/e2e_lib.sh"
107 107
108 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_hosts 10_agent 11_select 12_panes 13_birth 14_upgrade 15_askpass' 108 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_hosts 10_agent 11_select 12_panes 13_birth 14_upgrade 15_askpass 16_push'
109 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses 109 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses
110 # these by name rather than running them: a group whose fixtures are 110 # these by name rather than running them: a group whose fixtures are
111 # missing does not fail, it passes having tested something else — and a 111 # missing does not fail, it passes having tested something else — and a
@@ -170,8 +170,8 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "101" ] || { 173 [ "$OK_COUNT" = "105" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 101 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 105 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
test/e2e_01_boot.sh
Old New
@@ -439,11 +439,12 @@ refuse() {
439 # one is measured from the no-args usage path, which is NOT one of the 439 # one is measured from the no-args usage path, which is NOT one of the
440 # paths it bounds: if usage printing ever ballooned, the bound would 440 # paths it bounds: if usage printing ever ballooned, the bound would
441 # balloon with it and quietly stop catching a panic. The absolute one 441 # balloon with it and quietly stop catching a panic. The absolute one
442 # cannot drift, and 15 sits above any plausible complaint-plus-usage 442 # cannot drift, and 16 sits above any plausible complaint-plus-usage
443 # and below the ~20 lines of the smallest Debug panic. 443 # (the block is 15 lines since `upgrade HOST` joined it) and below the
444 # ~20 lines of the smallest Debug panic.
444 _usage_lines=$("$MUX" d 2>&1 | wc -l) 445 _usage_lines=$("$MUX" d 2>&1 | wc -l)
445 _lines=$(wc -l < "$OUT.q") 446 _lines=$(wc -l < "$OUT.q")
446 { [ "$_lines" -le $((_usage_lines + 2)) ] && [ "$_lines" -le 15 ]; } || { 447 { [ "$_lines" -le $((_usage_lines + 2)) ] && [ "$_lines" -le 16 ]; } || {
447 echo "e2e FAIL: mux d start $* answered with more than a message:"; cat "$OUT.q"; exit 1; 448 echo "e2e FAIL: mux d start $* answered with more than a message:"; cat "$OUT.q"; exit 1;
448 } 449 }
449 } 450 }
test/e2e_16_push.sh
Old New
@@ -0,0 +1,168 @@
1 # shellcheck shell=sh
2 # e2e_16_push.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may assume
4 # and what it must register.
5 # `mux d upgrade HOST` — the push flow: one preflight ssh whose line count is
6 # the verdict, the image streamed over a second ssh into an atomic rename,
7 # and the freshly installed binary run as `d upgrade` over a third. The
8 # "remote" is this box behind the ssh shim: a HOME of its own, a bare PATH,
9 # and the one mux it has installed at ~/.local/bin. Every claim the flow
10 # makes about that box is asserted against the fake box's own files and
11 # /proc, never against the pusher's report.
12
13 PUSHROOT="${TMPDIR:-/tmp}/mux-e2e-push-$$"
14 defer_rm "$PUSHROOT"
15 # The box that has no mux at all, for the refusal that must invent nothing.
16 PUSHBARE="${TMPDIR:-/tmp}/mux-e2e-pushbare-$$"
17 defer_rm "$PUSHBARE"
18 PUSHIM_DIR="${TMPDIR:-/tmp}/mux-e2e-pushshim-$$"
19 defer_rm "$PUSHIM_DIR"
20 PUSHSOCK="$PUSHROOT/run/muxd.sock"
21 defer_sock "$PUSHSOCK"
22 PUSHINST="$PUSHROOT/.local/bin/mux"
23
24 mkdir -p "$PUSHROOT/.local/bin" "$PUSHROOT/run" "$PUSHROOT/state" \
25 "$PUSHBARE" "$PUSHIM_DIR/fakebin"
26
27 # The shim IS ssh as far as the pusher can tell (e2e_04 explains that
28 # contract), and here it also IS the remote box: it swaps in the fake box's
29 # HOME, xdg dirs and a BARE PATH before handing the word to a shell. Bare,
30 # because the build's bin directory leads this suite's PATH, and a
31 # `command -v mux` that found the build's binary would be the fake remote
32 # answering with the pusher's own install. The word's appended
33 # `$HOME/.local/bin` is then the only place a mux can be found — which is
34 # exactly the claim the appended fallback makes. PUSHIM_HOME picks the box
35 # per scenario; PUSHIM_PATH lets the arch scenario front a lying uname.
36 ssh_shim_head "$PUSHIM_DIR/ssh"
37 cat >> "$PUSHIM_DIR/ssh" <<'SHIM'
38 shift
39 HOME="${PUSHIM_HOME:?}" \
40 XDG_RUNTIME_DIR="${PUSHIM_HOME:?}/run" \
41 XDG_STATE_HOME="${PUSHIM_HOME:?}/state" \
42 XDG_CONFIG_HOME="${PUSHIM_HOME:?}/config" \
43 XDG_CACHE_HOME="${PUSHIM_HOME:?}/cache" \
44 SHELL=/bin/bash \
45 PATH="${PUSHIM_PATH:-/usr/bin:/bin}" \
46 exec /bin/sh -c "$*"
47 SHIM
48 chmod +x "$PUSHIM_DIR/ssh"
49 PPATH="$PUSHIM_DIR:$PATH"
50
51 # The installed mux the push must replace: the real binary with a marker
52 # byte tail, so "replaced" and "untouched" are each one cmp/tail away and
53 # neither can pass by the two files having been identical all along. The
54 # appended tail changes no ELF header, so the marked copy still runs.
55 cp "$MUX_ELF" "$PUSHINST"
56 printf 'MUXOLDIMG' >> "$PUSHINST"
57 chmod 755 "$PUSHINST"
58
59 # --- refusal: a box with no mux gets nothing invented onto it ------------
60 if PUSHIM_HOME="$PUSHBARE" PATH="$PPATH" \
61 "$MUX" d upgrade pushbox > "$OUT.push_nomux" 2>&1; then
62 echo "e2e FAIL: push onto a box with no installed mux exited 0:"
63 cat "$OUT.push_nomux"
64 exit 1
65 fi
66 grep -q "no mux" "$OUT.push_nomux" || {
67 echo "e2e FAIL: the no-mux refusal never said so; the capture holds:"
68 cat "$OUT.push_nomux"
69 exit 1
70 }
71 # Refused means refused: the bare box gained no ~/.local/bin/mux.
72 [ ! -e "$PUSHBARE/.local/bin/mux" ] || {
73 echo "e2e FAIL: the no-mux refusal installed a mux anyway"
74 exit 1
75 }
76 ok "d upgrade HOST refuses a box with no installed mux"
77
78 # --- refusal: an arch mismatch is caught before any byte moves -----------
79 printf '#!/bin/sh\necho armv7l\n' > "$PUSHIM_DIR/fakebin/uname"
80 chmod +x "$PUSHIM_DIR/fakebin/uname"
81 if PUSHIM_HOME="$PUSHROOT" PUSHIM_PATH="$PUSHIM_DIR/fakebin:/usr/bin:/bin" \
82 PATH="$PPATH" "$MUX" d upgrade pushbox > "$OUT.push_arch" 2>&1; then
83 echo "e2e FAIL: push onto a mismatched arch exited 0:"
84 cat "$OUT.push_arch"
85 exit 1
86 fi
87 # Both spellings in the refusal, so the reader sees the mismatch rather
88 # than being told one exists.
89 grep -q "armv7l" "$OUT.push_arch" || {
90 echo "e2e FAIL: the arch refusal never named the remote's word; capture:"
91 cat "$OUT.push_arch"
92 exit 1
93 }
94 [ "$(tail -c 9 "$PUSHINST")" = "MUXOLDIMG" ] || {
95 echo "e2e FAIL: the arch refusal touched the installed binary"
96 exit 1
97 }
98 ok "d upgrade HOST refuses an arch mismatch before pushing"
99
100 # --- push with no daemon: install lands, nothing starts, exit 0 ----------
101 if ! PUSHIM_HOME="$PUSHROOT" PATH="$PPATH" \
102 "$MUX" d upgrade pushbox > "$OUT.push_idle" 2>&1; then
103 echo "e2e FAIL: push onto a daemonless box failed; the capture holds:"
104 cat "$OUT.push_idle"
105 exit 1
106 fi
107 grep -q "no daemon" "$OUT.push_idle" || {
108 echo "e2e FAIL: the daemonless push never said the daemon step was skipped:"
109 cat "$OUT.push_idle"
110 exit 1
111 }
112 # The install is byte-for-byte this build's image: marker gone, cmp clean.
113 cmp -s "$PUSHINST" "$MUX_ELF" || {
114 echo "e2e FAIL: after a daemonless push the installed mux is not this image"
115 exit 1
116 }
117 # A push is not a start: nothing may be listening on the fake box's socket.
118 [ ! -S "$PUSHSOCK" ] || {
119 echo "e2e FAIL: the daemonless push left a socket — something started a daemon"
120 exit 1
121 }
122 ok "d upgrade HOST with no remote daemon installs and starts nothing"
123
124 # --- push with a live daemon: same pid, new image, still serving ---------
125 # Marked again so this scenario, like the last, proves a replacement and
126 # not a coincidence of identical files.
127 printf 'MUXOLDIMG' >> "$PUSHINST"
128 # The remote daemon runs FROM the installed path with the fake box's own
129 # environment, exactly as a box that booted one would hold it. Foreground
130 # `d start` IS the daemon, so $! is the daemon's pid — the one the exec
131 # must keep.
132 env HOME="$PUSHROOT" XDG_RUNTIME_DIR="$PUSHROOT/run" \
133 XDG_STATE_HOME="$PUSHROOT/state" XDG_CONFIG_HOME="$PUSHROOT/config" \
134 XDG_CACHE_HOME="$PUSHROOT/cache" SHELL=/bin/bash \
135 "$PUSHINST" d start > "$OUT.pushd.log" 2>&1 &
136 PUSHDPID=$!
137 defer_kill "$PUSHDPID"
138 wait_sock "$PUSHSOCK" "$OUT.pushd.log" "push-target daemon"
139
140 if ! PUSHIM_HOME="$PUSHROOT" PATH="$PPATH" \
141 "$MUX" d upgrade pushbox --allow-same-version > "$OUT.push_live" 2>&1; then
142 echo "e2e FAIL: push onto a live daemon failed; the capture holds:"
143 cat "$OUT.push_live"
144 cat "$OUT.pushd.log"
145 exit 1
146 fi
147 # The remote trigger's own verdict, relayed verbatim through the ssh.
148 grep -q "upgraded to" "$OUT.push_live" || {
149 echo "e2e FAIL: the live push never reported the daemon upgrade:"
150 cat "$OUT.push_live"
151 exit 1
152 }
153 # The OS's account, not the daemon's: same pid, and its exe link names the
154 # installed path with no ' (deleted)' tail — the exec adopted the NEW inode
155 # at that path, rather than the old one riding out a rename.
156 kill -0 "$PUSHDPID" 2>/dev/null || {
157 echo "e2e FAIL: the daemon pid died across the push upgrade"
158 exit 1
159 }
160 [ "$(readlink "/proc/$PUSHDPID/exe")" = "$PUSHINST" ] || {
161 echo "e2e FAIL: /proc/$PUSHDPID/exe is $(readlink "/proc/$PUSHDPID/exe"), not $PUSHINST"
162 exit 1
163 }
164 cmp -s "$PUSHINST" "$MUX_ELF" || {
165 echo "e2e FAIL: after the live push the installed mux is not this image"
166 exit 1
167 }
168 ok "d upgrade HOST pushes the image and the remote daemon execs it in place"