5c9897e0
feat: mux d upgrade HOST pushes this image over ssh and upgrades there
a73x 2026-09-01 15:00
Commit message
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" | ||