a73x

d87843f2

fix: the push runs its candidate on the remote before renaming it in

a73x   2026-09-01 17:20

Commit message
fix: the push runs its candidate on the remote before renaming it in

uname -m passes when a Zen 5 desktop pushes at any x86_64 box, but an
architecture check cannot see CPU feature levels or a libc: a native
make-install image pushed at an Intel Core Ultra died SIGILL on every
ssh dial after the rename — silently, because the crash was the remote
end of every probe. Now the candidate must answer --version between
chmod and mv; one that cannot is removed and its true exit code relayed,
and the driver reads that code as the diagnosis — 132 names the CPU
feature mismatch, 126/127 a loader or libc the box lacks — so the
refusal says what to do (push a static release build) rather than only
that nothing was replaced.

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

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