d87843f2
fix: the push runs its candidate on the remote before renaming it in
a73x 2026-09-01 17:20
Commit message
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -731,6 +731,28 @@ fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { | |||
| 731 | /// `uname -m && command -v mux && mux d endpoint` — the bare endpoint verb, | 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 | 732 | /// which never starts a daemon — and the LINE COUNT is the diagnosis, because |
| 733 | /// `&&` stops at the first answerless step. | 733 | /// `&&` stops at the first answerless step. |
| 734 | /// What the push's exit says about the candidate, by VALUE: the push word's | ||
| 735 | /// failure arm relays the remote word's true exit code, and the shell's | ||
| 736 | /// conventions make two of them diagnoses. 132 is 128+SIGILL — the candidate | ||
| 737 | /// EXECUTED and died on an instruction the remote CPU lacks, which for an | ||
| 738 | /// arch-checked ELF means a feature-level mismatch (a native build pushed at | ||
| 739 | /// a weaker x86_64). 126/127 are the shell saying it could not run the | ||
| 740 | /// candidate at all — a dynamic loader or libc that box does not have. | ||
| 741 | /// Everything else is the push not landing for reasons the remote's own | ||
| 742 | /// stderr (inherited) already narrated. | ||
| 743 | const PushVerdict = enum { landed, cpu_mismatch, cannot_load, failed }; | ||
| 744 | |||
| 745 | fn pushVerdict(streamed: bool, term: std.process.Child.Term) PushVerdict { | ||
| 746 | if (!streamed) return .failed; | ||
| 747 | if (term != .Exited) return .failed; | ||
| 748 | return switch (term.Exited) { | ||
| 749 | 0 => .landed, | ||
| 750 | 132 => .cpu_mismatch, | ||
| 751 | 126, 127 => .cannot_load, | ||
| 752 | else => .failed, | ||
| 753 | }; | ||
| 754 | } | ||
| 755 | |||
| 734 | const Preflight = union(enum) { | 756 | const Preflight = union(enum) { |
| 735 | /// The ssh ran but stdout was empty: no shell spoke, nothing to trust. | 757 | /// The ssh ran but stdout was empty: no shell spoke, nothing to trust. |
| 736 | no_answer, | 758 | no_answer, |
| @@ -853,9 +875,29 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool | |||
| 853 | push.stdin.?.close(); | 875 | push.stdin.?.close(); |
| 854 | push.stdin = null; | 876 | push.stdin = null; |
| 855 | const push_term = try push.wait(); | 877 | const push_term = try push.wait(); |
| 856 | if (!streamed or push_term != .Exited or push_term.Exited != 0) { | 878 | switch (pushVerdict(streamed, push_term)) { |
| 857 | std.debug.print("mux d upgrade: the push to {s} did not land; nothing was replaced\n", .{host}); | 879 | .landed => {}, |
| 858 | return 1; | 880 | .cpu_mismatch => { |
| 881 | std.debug.print( | ||
| 882 | "mux d upgrade: {s}'s CPU cannot run this image — the candidate died on an " ++ | ||
| 883 | "illegal instruction, so the arch match was not a feature match; nothing " ++ | ||
| 884 | "was replaced. push a static release build instead\n", | ||
| 885 | .{host}, | ||
| 886 | ); | ||
| 887 | return 1; | ||
| 888 | }, | ||
| 889 | .cannot_load => { | ||
| 890 | std.debug.print( | ||
| 891 | "mux d upgrade: {s} cannot load this image (no loader or libc for it there); " ++ | ||
| 892 | "nothing was replaced\n", | ||
| 893 | .{host}, | ||
| 894 | ); | ||
| 895 | return 1; | ||
| 896 | }, | ||
| 897 | .failed => { | ||
| 898 | std.debug.print("mux d upgrade: the push to {s} did not land; nothing was replaced\n", .{host}); | ||
| 899 | return 1; | ||
| 900 | }, | ||
| 859 | } | 901 | } |
| 860 | 902 | ||
| 861 | if (!ready.daemon_up) { | 903 | if (!ready.daemon_up) { |
| @@ -1437,6 +1479,27 @@ test "parseArgs: subcommands and their existing flags" { | |||
| 1437 | try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg); | 1479 | try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg); |
| 1438 | } | 1480 | } |
| 1439 | 1481 | ||
| 1482 | test "pushVerdict: the relayed exit code names the mismatch uname -m cannot see" { | ||
| 1483 | const T = std.process.Child.Term; | ||
| 1484 | // 0 with the stream complete: the rename happened. | ||
| 1485 | try std.testing.expect(pushVerdict(true, T{ .Exited = 0 }) == .landed); | ||
| 1486 | // 132 is the shell's 128+SIGILL: a right-arch ELF whose instructions the | ||
| 1487 | // remote CPU lacks — the native-build push that bricked a live box | ||
| 1488 | // (2026-09-01) and the case the preflight's arch check cannot catch. | ||
| 1489 | try std.testing.expect(pushVerdict(true, T{ .Exited = 132 }) == .cpu_mismatch); | ||
| 1490 | // 126 and 127 are the shell's own cannot-execute codes: the candidate is | ||
| 1491 | // on disk but nothing there can load it — a dynamic interpreter or libc | ||
| 1492 | // the box does not have. | ||
| 1493 | try std.testing.expect(pushVerdict(true, T{ .Exited = 126 }) == .cannot_load); | ||
| 1494 | try std.testing.expect(pushVerdict(true, T{ .Exited = 127 }) == .cannot_load); | ||
| 1495 | // Everything else is the push not landing: ssh's own 255, a full disk's | ||
| 1496 | // 1, a killed ssh — and a clean exit whose stream broke midway, which is | ||
| 1497 | // a lying remote rather than a landed push. | ||
| 1498 | try std.testing.expect(pushVerdict(true, T{ .Exited = 255 }) == .failed); | ||
| 1499 | try std.testing.expect(pushVerdict(true, T{ .Signal = 15 }) == .failed); | ||
| 1500 | try std.testing.expect(pushVerdict(false, T{ .Exited = 0 }) == .failed); | ||
| 1501 | } | ||
| 1502 | |||
| 1440 | test "parsePreflight: the line count is the diagnosis" { | 1503 | test "parsePreflight: the line count is the diagnosis" { |
| 1441 | // Three lines: arch, the installed path, an endpoint announce — a daemon | 1504 | // Three lines: arch, the installed path, an endpoint announce — a daemon |
| 1442 | // is up and the full push-then-upgrade flow applies. | 1505 | // is up and the full push-then-upgrade flow applies. |
src/client/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -320,12 +320,19 @@ pub fn upgradePreflightArgv(alloc: std.mem.Allocator, host: []const u8) ![]const | |||
| 320 | return sshArgvWord(alloc, host, &.{}, word); | 320 | return sshArgvWord(alloc, host, &.{}, word); |
| 321 | } | 321 | } |
| 322 | 322 | ||
| 323 | /// The push: land the streamed image beside the installed mux, then rename | 323 | /// The push: land the streamed image beside the installed mux, run it once, |
| 324 | /// over it. Atomic on purpose — a connection dropped mid-stream must never | 324 | /// then rename over it. Atomic on purpose — a connection dropped mid-stream |
| 325 | /// leave a truncated binary at the installed path, and the running daemon | 325 | /// must never leave a truncated binary at the installed path, and the running |
| 326 | /// keeps its old inode undisturbed. `target` came off the remote's own | 326 | /// daemon keeps its old inode undisturbed. The `--version` between `chmod` |
| 327 | /// `command -v`; a single quote in it means something is lying, and this | 327 | /// and `mv` is the verdict the preflight's `uname -m` cannot give: an |
| 328 | /// refuses rather than escapes. | 328 | /// architecture check cannot see CPU feature levels or a libc, and a |
| 329 | /// CPU-native image pushed at a weaker box dies SIGILL on every ssh dial | ||
| 330 | /// AFTER the rename — silently, because the crash is the remote end of every | ||
| 331 | /// probe (found on a live box, 2026-09-01). A candidate that cannot answer | ||
| 332 | /// is removed and its exit code relayed, so a refused push leaves the box | ||
| 333 | /// exactly as it was found. `target` came off the remote's own `command -v`; | ||
| 334 | /// a single quote in it means something is lying, and this refuses rather | ||
| 335 | /// than escapes. | ||
| 329 | pub fn upgradePushArgv( | 336 | pub fn upgradePushArgv( |
| 330 | alloc: std.mem.Allocator, | 337 | alloc: std.mem.Allocator, |
| 331 | host: []const u8, | 338 | host: []const u8, |
| @@ -334,8 +341,10 @@ pub fn upgradePushArgv( | |||
| 334 | if (std.mem.indexOfScalar(u8, target, '\'') != null) return error.BadTargetPath; | 341 | if (std.mem.indexOfScalar(u8, target, '\'') != null) return error.BadTargetPath; |
| 335 | const word = try std.fmt.allocPrint( | 342 | const word = try std.fmt.allocPrint( |
| 336 | alloc, | 343 | alloc, |
| 337 | local_bin_append ++ "; cat > '{s}.new' && chmod 755 '{s}.new' && mv '{s}.new' '{s}'", | 344 | local_bin_append ++ "; cat > '{s}.new' && chmod 755 '{s}.new'" ++ |
| 338 | .{ target, target, target, target }, | 345 | " && '{s}.new' --version >/dev/null && mv '{s}.new' '{s}'" ++ |
| 346 | " || {{ s=$?; rm -f '{s}.new'; exit $s; }}", | ||
| 347 | .{ target, target, target, target, target, target }, | ||
| 339 | ); | 348 | ); |
| 340 | defer alloc.free(word); | 349 | defer alloc.free(word); |
| 341 | return sshArgvWord(alloc, host, &.{}, word); | 350 | return sshArgvWord(alloc, host, &.{}, word); |
| @@ -803,18 +812,26 @@ test "upgradePreflightArgv: one run, three answers, and the line count is the ve | |||
| 803 | }, argv); | 812 | }, argv); |
| 804 | } | 813 | } |
| 805 | 814 | ||
| 806 | test "upgradePushArgv: the image lands beside the target and renames over it" { | 815 | test "upgradePushArgv: the candidate must run on the remote before it replaces the install" { |
| 807 | const argv = try upgradePushArgv(std.testing.allocator, "user@box", "/home/u/.local/bin/mux"); | 816 | const argv = try upgradePushArgv(std.testing.allocator, "user@box", "/home/u/.local/bin/mux"); |
| 808 | defer freeArgv(std.testing.allocator, argv); | 817 | defer freeArgv(std.testing.allocator, argv); |
| 809 | // `.new` then `mv`: the rename is atomic, so a connection dropped | 818 | // `.new`, then the candidate EXECUTED, then `mv`: the rename is atomic, so |
| 810 | // mid-stream never leaves a truncated binary at the installed path, and | 819 | // a dropped connection never leaves a truncated binary at the installed |
| 811 | // a running daemon keeps its old inode undisturbed. | 820 | // path — and the execution is the verdict `uname -m` cannot give, because |
| 821 | // an architecture check cannot see CPU feature levels or a libc. A | ||
| 822 | // CPU-native desktop image pushed at an older x86_64 box died SIGILL on | ||
| 823 | // every ssh dial (2026-09-01) — with zero output, because the crash was | ||
| 824 | // the remote end of every probe. The failure arm removes the candidate | ||
| 825 | // and relays the true exit code, so the driver's "nothing was replaced" | ||
| 826 | // stays a fact and the box is left exactly as the push found it. | ||
| 812 | try expectArgv(&.{ | 827 | try expectArgv(&.{ |
| 813 | "ssh", | 828 | "ssh", |
| 814 | "user@box", | 829 | "user@box", |
| 815 | "PATH=\"$PATH:$HOME/.local/bin\"; cat > '/home/u/.local/bin/mux.new'" ++ | 830 | "PATH=\"$PATH:$HOME/.local/bin\"; cat > '/home/u/.local/bin/mux.new'" ++ |
| 816 | " && chmod 755 '/home/u/.local/bin/mux.new'" ++ | 831 | " && chmod 755 '/home/u/.local/bin/mux.new'" ++ |
| 817 | " && mv '/home/u/.local/bin/mux.new' '/home/u/.local/bin/mux'", | 832 | " && '/home/u/.local/bin/mux.new' --version >/dev/null" ++ |
| 833 | " && mv '/home/u/.local/bin/mux.new' '/home/u/.local/bin/mux'" ++ | ||
| 834 | " || { s=$?; rm -f '/home/u/.local/bin/mux.new'; exit $s; }", | ||
| 818 | }, argv); | 835 | }, argv); |
| 819 | } | 836 | } |
| 820 | 837 | ||