a73x

9d8e70b1

fix: the upgrade preflight reads Darwin's arm64 as aarch64

a73x   2026-09-04 10:16

Commit message
fix: the upgrade preflight reads Darwin's arm64 as aarch64

`mux d upgrade HOST` compares the box's `uname -m` against
`@tagName(builtin.cpu.arch)` as bytes. macOS prints `arm64` and zig has no
such tag — it spells that machine `aarch64` — so an Apple-silicon image
refused to push to an Apple-silicon box: the one pairing where the check
is certainly wrong, since it is the same hardware on both ends. The macOS
port therefore shipped with no working Mac-to-Mac remote upgrade.

`archMatches` maps that one pair, in one direction. `aarch64` is never
what a kernel prints here, so accepting it the other way would widen the
map for nothing, `x86_64` is already the same word on both systems, and
every other mismatch stays a refusal. The refusal message is untouched and
still carries the box's own spelling beside the image's — test/xos.sh's
upgrade-refused leg reads both of those sentences off the wire.

The pin is a unit test and has to be: `mux d upgrade` needs two boxes and
the fixture has one Mac, so `make xos` cannot reach this path. The test
covers the accepted pairing and both refusals that must survive the map.

Found by test/xos.sh (2026-09-04). The defect predates this branch —
`parsePreflight` arrived in 5c9897e0 — and is Darwin-only; nothing about
a Linux push changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

src/cli/main.zig
Old New
@@ -929,11 +929,30 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool
929 } 929 }
930 930
931 /// Slices into `out` — the caller keeps the buffer alive as long as the result. 931 /// Slices into `out` — the caller keeps the buffer alive as long as the result.
932 /// Whether the box's `uname -m` word names the machine `want_arch` does.
933 ///
934 /// The two sides spell one machine differently and neither is wrong: `uname -m`
935 /// on macOS prints `arm64`, and `want_arch` is `@tagName(builtin.cpu.arch)`,
936 /// which zig spells `aarch64` and has no `arm64` tag at all. Compared as bytes,
937 /// an Apple-silicon image refuses to push to an Apple-silicon box — the one
938 /// pairing where the check is certainly WRONG, since it is the same hardware on
939 /// both ends. `x86_64` is already the same word on every OS this runs on.
940 ///
941 /// One direction only, and one pair only: `aarch64` is never what a kernel
942 /// prints here, so accepting it in the other direction would widen the map for
943 /// nothing, and every other mismatch is a real refusal.
944 fn archMatches(reported: []const u8, want_arch: []const u8) bool {
945 if (std.mem.eql(u8, reported, want_arch)) return true;
946 return std.mem.eql(u8, reported, "arm64") and std.mem.eql(u8, want_arch, "aarch64");
947 }
948
932 fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight { 949 fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight {
933 var lines = std.mem.splitScalar(u8, out, '\n'); 950 var lines = std.mem.splitScalar(u8, out, '\n');
934 const arch = lines.next() orelse ""; 951 const arch = lines.next() orelse "";
935 if (arch.len == 0) return .no_answer; 952 if (arch.len == 0) return .no_answer;
936 if (!std.mem.eql(u8, arch, want_arch)) return .{ .bad_arch = arch }; 953 // The REPORTED word is what comes back on a refusal, so the message names
954 // the box's own spelling and the image's own spelling side by side.
955 if (!archMatches(arch, want_arch)) return .{ .bad_arch = arch };
937 const path = lines.next() orelse ""; 956 const path = lines.next() orelse "";
938 if (path.len == 0) return .no_mux; 957 if (path.len == 0) return .no_mux;
939 const announce = lines.next() orelse ""; 958 const announce = lines.next() orelse "";
@@ -1515,6 +1534,30 @@ test "parsePreflight: the line count is the diagnosis" {
1515 try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer); 1534 try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer);
1516 } 1535 }
1517 1536
1537 test "parsePreflight: a Mac's arm64 is the aarch64 this image is built for" {
1538 // The pairing this exists for: an Apple-silicon image pushing to an
1539 // Apple-silicon box. `uname -m` says `arm64`, `@tagName(builtin.cpu.arch)`
1540 // says `aarch64`, and a byte compare refused the one push that is
1541 // certainly safe. There is one Mac in the fixture and `mux d upgrade`
1542 // needs two, so `make xos` cannot reach this and the unit test is the pin.
1543 const mac = parsePreflight("arm64\n/Users/u/.local/bin/mux\nquic 1.2.3.4:1 k\n", "aarch64");
1544 try std.testing.expectEqualStrings("/Users/u/.local/bin/mux", mac.ready.path);
1545 try std.testing.expect(mac.ready.daemon_up);
1546
1547 // Neither half of the map loosens a real mismatch, and both refusals still
1548 // carry the BOX's spelling — test/xos.sh's upgrade-refused leg reads both
1549 // of these sentences off the wire in the two directions it pushes.
1550 const mac_from_intel = parsePreflight("arm64\n/Users/u/.local/bin/mux\n", "x86_64");
1551 try std.testing.expectEqualStrings("arm64", mac_from_intel.bad_arch);
1552 const intel_from_mac = parsePreflight("x86_64\n/home/u/.local/bin/mux\n", "aarch64");
1553 try std.testing.expectEqualStrings("x86_64", intel_from_mac.bad_arch);
1554
1555 // The map is one direction: a box that reports the zig tag is not a box
1556 // this has ever met, and it is not what makes an x86_64 image acceptable.
1557 try std.testing.expect(!archMatches("aarch64", "x86_64"));
1558 try std.testing.expect(archMatches("aarch64", "aarch64"));
1559 }
1560
1518 test "parseArgs: upgrade takes one HOST word, and only upgrade does" { 1561 test "parseArgs: upgrade takes one HOST word, and only upgrade does" {
1519 const r = parse(&.{ "d", "upgrade", "box" }); 1562 const r = parse(&.{ "d", "upgrade", "box" });
1520 try std.testing.expect(r == .command); 1563 try std.testing.expect(r == .command);