602384a1
refactor: a stale daemon image is an inode that moved, not a kernel suffix
a73x 2026-09-03 15:10
Commit message
src/os/server_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -105,6 +105,88 @@ pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t { | |||
| 105 | return impl.anonFd(name); | 105 | return impl.anonFd(name); |
| 106 | } | 106 | } |
| 107 | 107 | ||
| 108 | /// Identity of a file: the pair a rename-over changes and a rebuild in | ||
| 109 | /// place does not. Both halves matter — an inode number is only unique | ||
| 110 | /// within one filesystem, so an install that moved the image onto a | ||
| 111 | /// different mount can hand the new file the old file's inode number, and | ||
| 112 | /// a comparison by inode alone would call that daemon current. | ||
| 113 | const ImageIdent = struct { dev: u64, ino: u64 }; | ||
| 114 | |||
| 115 | fn imageIdent(path: []const u8) !ImageIdent { | ||
| 116 | // stat, not open-then-fstat: a stat needs only search permission on the | ||
| 117 | // directories, so an image installed mode 0111 is still gradeable | ||
| 118 | // rather than reported stale forever. `std.posix.fstatat` rather than | ||
| 119 | // `std.fs.cwd().statFile`, because the posix `Stat` reports the device | ||
| 120 | // and 0.15.2's `std.fs.File.Stat` does not. | ||
| 121 | const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0); | ||
| 122 | return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) }; | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Stale when the path now names a different inode than `at_boot`, or | ||
| 126 | /// nothing at all: `make install` and `mux d upgrade HOST` both rename a | ||
| 127 | /// new file over the running image, and the daemon keeps executing the | ||
| 128 | /// old one. A null `at_boot` is the born-stale case — the path named | ||
| 129 | /// nothing when this process started — and stays stale whatever the path | ||
| 130 | /// holds now, because a file that landed there afterwards is somebody | ||
| 131 | /// else's image and not the one being executed. | ||
| 132 | fn staleAgainst(at_boot: ?ImageIdent, path: []const u8) bool { | ||
| 133 | const boot = at_boot orelse return true; | ||
| 134 | const now = imageIdent(path) catch return true; | ||
| 135 | return now.ino != boot.ino or now.dev != boot.dev; | ||
| 136 | } | ||
| 137 | |||
| 138 | /// The path this process was started from and what that path held at the | ||
| 139 | /// time. A null `ident` means it held nothing: the record is still kept, | ||
| 140 | /// because knowing WHICH path is what separates "born on an unlinked | ||
| 141 | /// image" from "this OS would not name my path at all". | ||
| 142 | const BootImage = struct { | ||
| 143 | ident: ?ImageIdent, | ||
| 144 | path: [std.fs.max_path_bytes]u8, | ||
| 145 | len: usize, | ||
| 146 | |||
| 147 | fn spelling(self: *const BootImage) []const u8 { | ||
| 148 | return self.path[0..self.len]; | ||
| 149 | } | ||
| 150 | }; | ||
| 151 | |||
| 152 | fn recordImage(path: []const u8) BootImage { | ||
| 153 | var rec: BootImage = .{ .ident = imageIdent(path) catch null, .path = undefined, .len = path.len }; | ||
| 154 | @memcpy(rec.path[0..path.len], path); | ||
| 155 | return rec; | ||
| 156 | } | ||
| 157 | |||
| 158 | var boot_image: ?BootImage = null; | ||
| 159 | |||
| 160 | /// Record the running image's path and identity. Called once at daemon | ||
| 161 | /// start; a later call is a no-op, so the comparison is always against | ||
| 162 | /// boot and a rename that lands a file at the path afterwards can never | ||
| 163 | /// promote a stale daemon back to current. | ||
| 164 | /// | ||
| 165 | /// The two failures are not the same answer. A path this OS will not name | ||
| 166 | /// at all (`selfExePath` fails) records NOTHING and `selfImageStale` then | ||
| 167 | /// answers false — unknown is not stale, because a wall must not dress a | ||
| 168 | /// healthy box in a warning over a refused readlink. A path that IS named | ||
| 169 | /// but holds nothing (Linux spells a deleted image `…/mux (deleted)`, which | ||
| 170 | /// is how `spawn.selfExe`'s `/proc/self/exe` fallback boots a daemon) is a | ||
| 171 | /// daemon already running an image no path holds: that records the path | ||
| 172 | /// with no ident, and every ask answers stale. | ||
| 173 | pub fn noteBootImage() void { | ||
| 174 | if (boot_image != null) return; | ||
| 175 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 176 | const p = std.fs.selfExePath(&buf) catch return; | ||
| 177 | boot_image = recordImage(p); | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Has the file at the running image's path been replaced since boot. | ||
| 181 | /// Read fresh per ask: a rename lands under a running daemon at any moment, | ||
| 182 | /// and one stat per `sessions_req` is nothing. Implemented here and in no | ||
| 183 | /// arm, because it is the same rule on every OS an arm could be written for. | ||
| 184 | pub fn selfImageStale() bool { | ||
| 185 | noteBootImage(); | ||
| 186 | if (boot_image) |*b| return staleAgainst(b.ident, b.spelling()); | ||
| 187 | return false; | ||
| 188 | } | ||
| 189 | |||
| 108 | test "server_os: the arm compiles and answers for the process it is in" { | 190 | test "server_os: the arm compiles and answers for the process it is in" { |
| 109 | try std.testing.expect(getpid() > 0); | 191 | try std.testing.expect(getpid() > 0); |
| 110 | } | 192 | } |
| @@ -194,6 +276,57 @@ test "server_os.forkDetached: the child is a session leader writing to the fd it | |||
| 194 | try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); | 276 | try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); |
| 195 | } | 277 | } |
| 196 | 278 | ||
| 279 | test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" { | ||
| 280 | // The test binary cannot be renamed under itself safely, so the rule is | ||
| 281 | // exercised on a copy in a temp dir through the same two functions with | ||
| 282 | // the path named explicitly. | ||
| 283 | var tmp = std.testing.tmpDir(.{}); | ||
| 284 | defer tmp.cleanup(); | ||
| 285 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v1" }); | ||
| 286 | var pbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 287 | const path = try tmp.dir.realpath("img", &pbuf); | ||
| 288 | var ident = try imageIdent(path); | ||
| 289 | try std.testing.expect(!staleAgainst(ident, path)); | ||
| 290 | try tmp.dir.writeFile(.{ .sub_path = "img.new", .data = "v2" }); | ||
| 291 | try tmp.dir.rename("img.new", "img"); | ||
| 292 | try std.testing.expect(staleAgainst(ident, path)); | ||
| 293 | ident = try imageIdent(path); | ||
| 294 | try std.testing.expect(!staleAgainst(ident, path)); | ||
| 295 | try tmp.dir.deleteFile("img"); | ||
| 296 | try std.testing.expect(staleAgainst(ident, path)); | ||
| 297 | } | ||
| 298 | |||
| 299 | test "server_os.selfImageStale: a daemon born on an unlinked image never reads healthy" { | ||
| 300 | // The route on Linux: `spawn.selfExe` hands the daemon `/proc/self/exe` | ||
| 301 | // exactly when the resolved path is gone, so the process can boot on an | ||
| 302 | // image no path names. `selfExePath` still SPELLS that path (with the | ||
| 303 | // kernel's suffix), and a spelled path that stats to nothing must read | ||
| 304 | // stale — the old suffix check said so, and a false "healthy" is the one | ||
| 305 | // verdict this check exists to never give. | ||
| 306 | var tmp = std.testing.tmpDir(.{}); | ||
| 307 | defer tmp.cleanup(); | ||
| 308 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v1" }); | ||
| 309 | var pbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 310 | const path = try tmp.dir.realpath("img", &pbuf); | ||
| 311 | try tmp.dir.deleteFile("img"); | ||
| 312 | const born = recordImage(path); | ||
| 313 | try std.testing.expect(born.ident == null); | ||
| 314 | try std.testing.expect(staleAgainst(born.ident, born.spelling())); | ||
| 315 | // A later file at that path is somebody else's image, not the one this | ||
| 316 | // process is executing, so the verdict does not go back to healthy. | ||
| 317 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v2" }); | ||
| 318 | try std.testing.expect(staleAgainst(born.ident, born.spelling())); | ||
| 319 | } | ||
| 320 | |||
| 321 | test "server_os.noteBootImage: the live binary is its own image, and a second call is a no-op" { | ||
| 322 | // The cheap in-process pin, through the PUBLIC pair: this test binary was | ||
| 323 | // not renamed under itself, so it must read healthy however many times | ||
| 324 | // the record is asked for. | ||
| 325 | noteBootImage(); | ||
| 326 | noteBootImage(); | ||
| 327 | try std.testing.expect(!selfImageStale()); | ||
| 328 | } | ||
| 329 | |||
| 197 | test "server_os.anonFd: no path names it, and it is not CLOEXEC" { | 330 | test "server_os.anonFd: no path names it, and it is not CLOEXEC" { |
| 198 | const fd = try anonFd("mux-test-carrier"); | 331 | const fd = try anonFd("mux-test-carrier"); |
| 199 | defer std.posix.close(fd); | 332 | defer std.posix.close(fd); |
src/os/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,14 +1,22 @@ | |||
| 1 | //! Resolve the executable used when mux starts a daemon process. Following | 1 | //! Resolve the executable used when mux starts a daemon process. Resolving |
| 2 | //! `/proc/self/exe` ensures the new process runs the current binary rather than | 2 | //! the running image ensures the new process runs the current binary rather |
| 3 | //! another `mux` found through `PATH`. | 3 | //! than another `mux` found through `PATH`. |
| 4 | const std = @import("std"); | 4 | const std = @import("std"); |
| 5 | const builtin = @import("builtin"); | ||
| 5 | 6 | ||
| 6 | /// Kernel link to the running executable, used when the resolved path is no | 7 | /// The kernel's link to the running image, used only when the resolved |
| 7 | /// longer executable. | 8 | /// path is no longer executable — Linux keeps a live link after a rename- |
| 8 | pub const self_exe = "/proc/self/exe"; | 9 | /// over. On an OS with no such link the fallback is the resolved path |
| 10 | /// itself, and an exec of a replaced image fails where it always would. | ||
| 11 | pub const self_exe: []const u8 = switch (builtin.os.tag) { | ||
| 12 | .linux => "/proc/self/exe", | ||
| 13 | else => "", | ||
| 14 | }; | ||
| 9 | 15 | ||
| 10 | /// Return the resolved path of the current executable, falling back to | 16 | /// Return the resolved path of the current executable, falling back to |
| 11 | /// `/proc/self/exe`. | 17 | /// this OS's link to the running image. On an OS with no such link an |
| 18 | /// unresolvable path comes back empty and the exec fails with it: naming | ||
| 19 | /// `mux` instead would be the PATH walk this module exists to prevent. | ||
| 12 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { | 20 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { |
| 13 | // Prefer the resolved path because process listings derive `comm` from the | 21 | // Prefer the resolved path because process listings derive `comm` from the |
| 14 | // filename passed to execve; executing the link would name every daemon | 22 | // filename passed to execve; executing the link would name every daemon |
| @@ -16,12 +24,14 @@ pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { | |||
| 16 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); | 24 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); |
| 17 | } | 25 | } |
| 18 | 26 | ||
| 19 | /// The resolved path if it can still be exec'd, the /proc link if it cannot. | 27 | /// The resolved path if it can still be exec'd, this OS's link to the |
| 28 | /// running image if it cannot and it has one. | ||
| 20 | /// Split out so the fallback is assertable without deleting a live binary. | 29 | /// Split out so the fallback is assertable without deleting a live binary. |
| 21 | fn execOrLink(resolved: []const u8) []const u8 { | 30 | fn execOrLink(resolved: []const u8) []const u8 { |
| 22 | // After `make install`, the resolved path may end in ` (deleted)` and no | 31 | // After `make install`, the resolved path may end in ` (deleted)` on |
| 23 | // longer be executable even though readlink succeeded. | 32 | // Linux and no longer be executable even though the readlink succeeded. |
| 24 | std.posix.access(resolved, std.posix.X_OK) catch return self_exe; | 33 | std.posix.access(resolved, std.posix.X_OK) catch |
| 34 | return if (self_exe.len == 0) resolved else self_exe; | ||
| 25 | return resolved; | 35 | return resolved; |
| 26 | } | 36 | } |
| 27 | 37 | ||
| @@ -43,9 +53,13 @@ test "selfExe: a resolved path that is no longer a file falls back to the link" | |||
| 43 | const live = try std.fs.selfExePath(&buf); | 53 | const live = try std.fs.selfExePath(&buf); |
| 44 | try std.testing.expectEqualStrings(live, execOrLink(live)); | 54 | try std.testing.expectEqualStrings(live, execOrLink(live)); |
| 45 | 55 | ||
| 46 | var gone: [std.fs.max_path_bytes]u8 = undefined; | 56 | // The suffix is Linux's; an OS with no such link has no fallback path |
| 47 | const deleted = try std.fmt.bufPrint(&gone, "{s} (deleted)", .{live}); | 57 | // to assert, and `execOrLink` hands back what it was given. |
| 48 | try std.testing.expectEqualStrings(self_exe, execOrLink(deleted)); | 58 | if (builtin.os.tag == .linux) { |
| 59 | var gone: [std.fs.max_path_bytes]u8 = undefined; | ||
| 60 | const deleted = try std.fmt.bufPrint(&gone, "{s} (deleted)", .{live}); | ||
| 61 | try std.testing.expectEqualStrings(self_exe, execOrLink(deleted)); | ||
| 62 | } | ||
| 49 | } | 63 | } |
| 50 | 64 | ||
| 51 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 65 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -542,6 +542,10 @@ pub const Server = struct { | |||
| 542 | }; | 542 | }; |
| 543 | 543 | ||
| 544 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 544 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| 545 | // Before any request can ask: the comparison is against the image | ||
| 546 | // that BOOTED, not the first one asked about. | ||
| 547 | server_os.noteBootImage(); | ||
| 548 | |||
| 545 | // Before the shell is spawned, so refusing costs nobody a fork and | 549 | // Before the shell is spawned, so refusing costs nobody a fork and |
| 546 | // leaves no process to reap. `serve.bind` below runs the same refusal | 550 | // leaves no process to reap. `serve.bind` below runs the same refusal |
| 547 | // again, and the repeat is not redundant: it is the one that decides, | 551 | // again, and the repeat is not redundant: it is the one that decides, |
| @@ -603,6 +607,13 @@ pub const Server = struct { | |||
| 603 | parsed: *const upgrade.Parsed, | 607 | parsed: *const upgrade.Parsed, |
| 604 | version: []const u8, | 608 | version: []const u8, |
| 605 | ) !Server { | 609 | ) !Server { |
| 610 | // Before any request can ask: the comparison is against the image | ||
| 611 | // that BOOTED, not the first one asked about. `mux d upgrade` keeps | ||
| 612 | // the pid but execs a new image, which zeroes every global — so this | ||
| 613 | // records the CANDIDATE, and the daemon is graded against the file | ||
| 614 | // it is now running rather than the one it started life as. | ||
| 615 | server_os.noteBootImage(); | ||
| 616 | |||
| 606 | // Same pid, same children, same descriptors. No `sockpath.claim`: the | 617 | // Same pid, same children, same descriptors. No `sockpath.claim`: the |
| 607 | // inherited listener fd IS the claim, and claim's probe would find our | 618 | // inherited listener fd IS the claim, and claim's probe would find our |
| 608 | // own socket answering. `version` is THIS binary's, never the | 619 | // own socket answering. `version` is THIS binary's, never the |
| @@ -1808,7 +1819,7 @@ pub const Server = struct { | |||
| 1808 | const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8))); | 1819 | const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8))); |
| 1809 | len = proto.appendSessionsHolds(&buf, len, s.name(), holds); | 1820 | len = proto.appendSessionsHolds(&buf, len, s.name(), holds); |
| 1810 | } | 1821 | } |
| 1811 | len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale()); | 1822 | len = proto.appendSessionsMeta(&buf, len, self.version, server_os.selfImageStale()); |
| 1812 | } | 1823 | } |
| 1813 | self.replyTo(p, .sessions_reply, buf[0..len]); | 1824 | self.replyTo(p, .sessions_reply, buf[0..len]); |
| 1814 | }, | 1825 | }, |
| @@ -3193,21 +3204,6 @@ pub const Server = struct { | |||
| 3193 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); | 3204 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); |
| 3194 | } | 3205 | } |
| 3195 | 3206 | ||
| 3196 | /// Whether the file this daemon was exec'd from has been replaced or | ||
| 3197 | /// removed since: `/proc/self/exe` keeps resolving THROUGH to the old | ||
| 3198 | /// image, so the daemon runs fine, but the kernel appends " (deleted)" | ||
| 3199 | /// to the link's text. That suffix is the whole staleness check — no | ||
| 3200 | /// version compare can catch it, because a rebuild of the same dev | ||
| 3201 | /// version spells the same string. Read fresh per ask: a rename lands | ||
| 3202 | /// under a running daemon at any moment, and one readlink per | ||
| 3203 | /// `sessions_req` is nothing. Unknown is reported not-stale — a wall | ||
| 3204 | /// must not dress a healthy box in a warning because /proc was coy. | ||
| 3205 | fn selfImageStale() bool { | ||
| 3206 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 3207 | const p = std.posix.readlink("/proc/self/exe", &buf) catch return false; | ||
| 3208 | return std.mem.endsWith(u8, p, " (deleted)"); | ||
| 3209 | } | ||
| 3210 | |||
| 3211 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. | 3207 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. |
| 3212 | fn liveClients(self: *const Server) usize { | 3208 | fn liveClients(self: *const Server) usize { |
| 3213 | return countLive(&self.clients); | 3209 | return countLive(&self.clients); |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -1653,9 +1653,9 @@ ok "the entry dial relays ssh's stderr and fails in ssh's own words" | |||
| 1653 | # running: the daemon keeps serving the old image, every long-lived wall | 1653 | # running: the daemon keeps serving the old image, every long-lived wall |
| 1654 | # keeps painting with it, and for months nothing anywhere said so — the | 1654 | # keeps painting with it, and for months nothing anywhere said so — the |
| 1655 | # night of 2026-09-01 was a wall flooding its screen with a bug that had | 1655 | # night of 2026-09-01 was a wall flooding its screen with a bug that had |
| 1656 | # been FIXED on disk for three days. The daemon now reads its own | 1656 | # been FIXED on disk for three days. The daemon now stats its own image's |
| 1657 | # /proc/self/exe per sessions_req and reports the kernel's ` (deleted)` | 1657 | # path per sessions_req and reports a different inode than the one it |
| 1658 | # suffix as `stale`; the poll carries it; the bar wears it. | 1658 | # booted on as `stale`; the poll carries it; the bar wears it. |
| 1659 | # | 1659 | # |
| 1660 | # Client and daemon here are the SAME build, so the version half of the | 1660 | # Client and daemon here are the SAME build, so the version half of the |
| 1661 | # drift word stays silent and `daemon stale` is the whole of it — which | 1661 | # drift word stays silent and `daemon stale` is the whole of it — which |
| @@ -1692,8 +1692,8 @@ grep -aq "daemon stale" "$OUT.sb1" && { | |||
| 1692 | exit 1; } | 1692 | exit 1; } |
| 1693 | 1693 | ||
| 1694 | # The install, by the same inode dance install(1) does: a new file RENAMED | 1694 | # The install, by the same inode dance install(1) does: a new file RENAMED |
| 1695 | # over the old, so the running daemon's exe link goes ` (deleted)`. A | 1695 | # over the old, so the path the daemon booted on now names a different |
| 1696 | # truncating copy would reuse the inode and prove nothing. | 1696 | # inode. A truncating copy would reuse the inode and prove nothing. |
| 1697 | cp "$MUX" "$SBBIN/mux.new" | 1697 | cp "$MUX" "$SBBIN/mux.new" |
| 1698 | mv -f "$SBBIN/mux.new" "$SBBIN/mux" | 1698 | mv -f "$SBBIN/mux.new" "$SBBIN/mux" |
| 1699 | 1699 | ||