a73x

602384a1

refactor: a stale daemon image is an inode that moved, not a kernel suffix

a73x   2026-09-03 15:10

Commit message
refactor: a stale daemon image is an inode that moved, not a kernel suffix

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

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