a73x

b296da2b

fix: muxd run refuses a live socket instead of stealing it

a73x   2026-08-08 14:08

Commit message
fix: muxd run refuses a live socket instead of stealing it

docs/decisions.md
Old New
@@ -426,6 +426,35 @@ path, and it is transport work, which is the verdict restated.
426 not a footnote; `wan.sh`'s netem stanza adds delay on egress only, so 426 not a footnote; `wan.sh`'s netem stanza adds delay on egress only, so
427 `delay 75ms` is ~75ms of added round trip and not ~150ms (the script 427 `delay 75ms` is ~75ms of added round trip and not ~150ms (the script
428 says so; the plan's parenthetical said otherwise). 428 says so; the plan's parenthetical said otherwise).
429 - **`muxd run` refuses a live socket rather than stealing it (post-M6
430 field fix).** The incident: three daemons were started against one
431 path, each unlinking it and binding fresh. None of them died — the
432 older two kept running with their sessions and shells intact but
433 permanently unreachable, and the user's two terminals were attached to
434 two *different* sessions, which made every cross-client behaviour
435 (latest-wins, shared output) look broken. Init now probes the path
436 first: something answers → `error.DaemonAlreadyRunning` and no unlink;
437 nothing there → bind; a socket file nobody answers on → a dead daemon's
438 leftover, unlink and rebind. The rule is **unlink only what answers
439 ECONNREFUSED *and* stats as a socket** — necessary because Linux
440 answers ECONNREFUSED for a *regular file* at the path exactly as it
441 does for a dead socket, so the connect result alone would have made
442 `muxd run --sock notes.txt` delete notes.txt. The probe is skipped
443 under systemd socket activation, where the path is systemd's to manage,
444 and it runs before the pty is spawned so a refusal costs no fork. This
445 is the complementary half of deinit's existing unlink-only-if-still-ours
446 check: one guards the path on the way in, the other on the way out.
447 - **Banked cleanup (post-M6, from the socket-claim review):** the systemd
448 `LISTEN_FDS` path has no automated test proving the probe is skipped
449 (verified by hand only). `claimSockPath` still stack-traces on
450 AccessDenied / NameTooLong / an absent parent directory — cosmetic, the
451 same one-line treatment `DaemonAlreadyRunning` gets would do. The
452 stat→unlink window is a TOCTOU race, accepted as non-adversarial: a
453 socket path a hostile process can swap under us is already a directory
454 we do not control, and closing it properly means a tmux-style lockfile
455 beside the socket, not a cleverer stat. That `fstatat` FileNotFound arm
456 is consequently unreachable from any test — it exists for that window
457 alone.
429 - **Owed before this is more than a prototype, in order:** TLS or QUIC 458 - **Owed before this is more than a prototype, in order:** TLS or QUIC
430 for any deployment that is not tunnelled through SSH — `--via` borrows 459 for any deployment that is not tunnelled through SSH — `--via` borrows
431 ssh's authentication and encryption entirely, and has none of its own; 460 ssh's authentication and encryption entirely, and has none of its own;
src/main.zig
Old New
@@ -69,12 +69,31 @@ pub fn main() !u8 {
69 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); 69 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
70 defer alloc.free(shell_z); 70 defer alloc.free(shell_z);
71 71
72 var srv = try Server.init(alloc, .{ 72 var srv = Server.init(alloc, .{
73 .sock_path = sock_path, 73 .sock_path = sock_path,
74 .shell = shell_z, 74 .shell = shell_z,
75 .cols = cols, 75 .cols = cols,
76 .rows = rows, 76 .rows = rows,
77 }); 77 }) catch |err| switch (err) {
78 // All of these mean "that path is not ours to take", and all
79 // are ordinary operator mistakes rather than daemon bugs: say
80 // so in one line and exit, no stack trace.
81 //
82 // AddressInUse is the same situation found one syscall later:
83 // two daemons starting at once can both see an empty path and
84 // both try to bind it. The loser has simply lost a dead heat,
85 // and telling it "a daemon is already running" is exactly
86 // right — by the time it reads the message, one is.
87 error.DaemonAlreadyRunning, error.AddressInUse => {
88 std.debug.print("muxd: a daemon is already running on {s}\n", .{sock_path});
89 return 1;
90 },
91 error.SockPathNotASocket => {
92 std.debug.print("muxd: {s} exists and is not a socket\n", .{sock_path});
93 return 1;
94 },
95 else => return err,
96 };
78 defer srv.deinit(); 97 defer srv.deinit();
79 @import("server").installSignalHandlers(); 98 @import("server").installSignalHandlers();
80 return try srv.run(); 99 return try srv.run();
src/server.zig
Old New
@@ -251,6 +251,16 @@ pub const Server = struct {
251 }; 251 };
252 252
253 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { 253 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server {
254 // systemd socket activation: LISTEN_FDS=1 hands us the listener as
255 // fd 3, and the path is systemd's to manage — we neither probe nor
256 // unlink it. Read once, up front, so the claim below can be the
257 // first thing that happens on the self-bind path.
258 const systemd_fd = listenFdFromSystemd();
259
260 // Before the shell is spawned, so refusing costs nobody a fork and
261 // leaves no process to reap.
262 if (systemd_fd == null) try claimSockPath(opts.sock_path);
263
254 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); 264 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows });
255 errdefer eng.deinit(); 265 errdefer eng.deinit();
256 266
@@ -263,8 +273,7 @@ pub const Server = struct {
263 var epoch: u64 = 0; 273 var epoch: u64 = 0;
264 while (epoch == 0) epoch = std.crypto.random.int(u64); 274 while (epoch == 0) epoch = std.crypto.random.int(u64);
265 275
266 // systemd socket activation: LISTEN_FDS=1 hands us the listener as fd 3. 276 if (systemd_fd) |fd| {
267 if (listenFdFromSystemd()) |fd| {
268 return .{ 277 return .{
269 .alloc = alloc, 278 .alloc = alloc,
270 .eng = eng, 279 .eng = eng,
@@ -279,7 +288,6 @@ pub const Server = struct {
279 }; 288 };
280 } 289 }
281 290
282 std.fs.cwd().deleteFile(opts.sock_path) catch {};
283 const addr = try std.net.Address.initUnix(opts.sock_path); 291 const addr = try std.net.Address.initUnix(opts.sock_path);
284 return .{ 292 return .{
285 .alloc = alloc, 293 .alloc = alloc,
@@ -292,6 +300,51 @@ pub const Server = struct {
292 }; 300 };
293 } 301 }
294 302
303 /// Make the socket path ours to bind, or refuse it. Field incident this
304 /// exists for: three daemons were started against one path, each
305 /// unlinking it and binding fresh. Every one of them kept running with
306 /// its sessions intact, but only the newest was reachable — the older
307 /// two were stranded, invisible, holding shells nobody could get back
308 /// to, and two terminals "in the same session" were really in two
309 /// different ones.
310 ///
311 /// So: unlink only what answers ECONNREFUSED *and* is a socket.
312 /// - something answers → a live daemon owns this path. Refuse.
313 /// - nothing there → bind, nothing to clean up.
314 /// - a dead socket file → a daemon that died without deinit's
315 /// unlink running. Ours to clear.
316 /// - anything else → propagate. A path we cannot positively
317 /// identify as a dead daemon's leftover is not
318 /// something we may delete.
319 fn claimSockPath(path: []const u8) !void {
320 if (std.net.connectUnixSocket(path)) |probe| {
321 probe.close();
322 return error.DaemonAlreadyRunning;
323 } else |err| switch (err) {
324 error.FileNotFound => return, // free path; bind straight away
325 // Nobody is listening — but this is NOT yet proof of a stale
326 // socket: Linux answers ECONNREFUSED for a regular file at the
327 // path exactly as it does for a dead socket, so connect alone
328 // cannot tell a dead daemon from `muxd run --sock notes.txt`.
329 // The stat below is what separates them.
330 error.ConnectionRefused => {},
331 else => |e| return e,
332 }
333
334 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
335 error.FileNotFound => return, // vanished under us; path is free
336 else => |e| return e,
337 };
338 if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket;
339
340 std.fs.cwd().deleteFile(path) catch |err| switch (err) {
341 // Someone else cleared it first. The path is free either way,
342 // which is the only thing this function was after.
343 error.FileNotFound => {},
344 else => |e| return e,
345 };
346 }
347
295 fn listenFdFromSystemd() ?std.posix.fd_t { 348 fn listenFdFromSystemd() ?std.posix.fd_t {
296 const pid_s = std.posix.getenv("LISTEN_PID") orelse return null; 349 const pid_s = std.posix.getenv("LISTEN_PID") orelse return null;
297 const nfds_s = std.posix.getenv("LISTEN_FDS") orelse return null; 350 const nfds_s = std.posix.getenv("LISTEN_FDS") orelse return null;
@@ -2653,6 +2706,155 @@ test "Server: a daemon restart invalidates have_seq even with the old epoch pres
2653 } 2706 }
2654 } 2707 }
2655 2708
2709 /// Test helper: assert `Server.init` refuses `path` with `want`.
2710 ///
2711 /// The teardown on the failing branch is not tidiness. A Server that got
2712 /// built when it should not have owns a live shell on a pty, and letting
2713 /// the test discard it leaves that shell holding the test runner's stdout:
2714 /// the build then never sees EOF and hangs for hours instead of printing a
2715 /// failure. A regression in the refusal must cost one red test, not a wedged
2716 /// CI worker — which is exactly how the daemon-stealing bug hid in the first
2717 /// place.
2718 fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void {
2719 if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| {
2720 var stolen = built;
2721 stolen.deinit();
2722 return error.InitShouldHaveRefused;
2723 } else |err| {
2724 try std.testing.expectEqual(want, err);
2725 }
2726 }
2727
2728 test "Server: a second daemon refuses a live socket instead of stealing it" {
2729 const alloc = std.testing.allocator;
2730
2731 var tmp = std.testing.tmpDir(.{});
2732 defer tmp.cleanup();
2733 var path_buf: [256]u8 = undefined;
2734 const dir_path = try tmp.dir.realpath(".", &path_buf);
2735 const sock_path = try std.fmt.allocPrint(alloc, "{s}/live.sock", .{dir_path});
2736 defer alloc.free(sock_path);
2737
2738 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
2739 defer srv.deinit();
2740
2741 var stop = std.atomic.Value(bool).init(false);
2742 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
2743 defer th.join();
2744 defer stop.store(true, .release);
2745
2746 // The incident: this used to unlink the path and bind over it, leaving
2747 // the first daemon running and unreachable.
2748 try expectInitRefused(alloc, sock_path, error.DaemonAlreadyRunning);
2749
2750 // The loser touched nothing: the socket file is still there...
2751 const st = try std.posix.fstatat(std.posix.AT.FDCWD, sock_path, 0);
2752 try std.testing.expect(std.posix.S.ISSOCK(st.mode));
2753
2754 // ...and it still reaches the daemon that was already there. The epoch
2755 // is what makes that precise: it names one daemon *instance*, so
2756 // matching it rules out having been handed a replacement.
2757 const c = try std.net.connectUnixSocket(sock_path);
2758 defer c.close();
2759 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
2760 const first = try firstStateFrame(alloc, c.handle, 10_000);
2761 try std.testing.expect(first != null);
2762 try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type);
2763 try std.testing.expectEqual(srv.epoch, first.?.epoch);
2764 }
2765
2766 test "Server: a dead daemon's leftover socket file is cleared and rebound" {
2767 const alloc = std.testing.allocator;
2768
2769 var tmp = std.testing.tmpDir(.{});
2770 defer tmp.cleanup();
2771 var path_buf: [256]u8 = undefined;
2772 const dir_path = try tmp.dir.realpath(".", &path_buf);
2773 const sock_path = try std.fmt.allocPrint(alloc, "{s}/stale.sock", .{dir_path});
2774 defer alloc.free(sock_path);
2775
2776 // What a daemon killed with SIGKILL leaves behind: a bound socket file
2777 // whose listener is gone, since deinit's unlink never ran.
2778 {
2779 const addr = try std.net.Address.initUnix(sock_path);
2780 var dead = try addr.listen(.{});
2781 dead.deinit(); // closes the fd; the file stays on disk
2782 }
2783 const before = try std.posix.fstatat(std.posix.AT.FDCWD, sock_path, 0);
2784 try std.testing.expect(std.posix.S.ISSOCK(before.mode));
2785
2786 // Nobody answers there, so the path is ours: recovery, not refusal.
2787 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
2788 defer srv.deinit();
2789
2790 var stop = std.atomic.Value(bool).init(false);
2791 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
2792 defer th.join();
2793 defer stop.store(true, .release);
2794
2795 // And the rebind is real, not just a file that reappeared: it serves.
2796 const c = try std.net.connectUnixSocket(sock_path);
2797 defer c.close();
2798 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
2799 const first = try firstStateFrame(alloc, c.handle, 10_000);
2800 try std.testing.expect(first != null);
2801 try std.testing.expectEqual(srv.epoch, first.?.epoch);
2802 }
2803
2804 test "Server: a path that cannot be bound fails as AddressInUse" {
2805 const alloc = std.testing.allocator;
2806
2807 var tmp = std.testing.tmpDir(.{});
2808 defer tmp.cleanup();
2809 var path_buf: [256]u8 = undefined;
2810 const dir_path = try tmp.dir.realpath(".", &path_buf);
2811 const sock_path = try std.fmt.allocPrint(alloc, "{s}/dangling.sock", .{dir_path});
2812 defer alloc.free(sock_path);
2813
2814 // A dangling symlink reaches bind() the way a lost start-up race does,
2815 // but deterministically: connect through it gets ENOENT (so the probe
2816 // reads the path as free, exactly as the loser of a race does), while
2817 // bind() gets EADDRINUSE off the directory entry the symlink itself
2818 // occupies. Racing two real daemons would test the same prong by
2819 // coin-flip; this pins it every run.
2820 try tmp.dir.symLink("no-such-target", "dangling.sock", .{});
2821
2822 try expectInitRefused(alloc, sock_path, error.AddressInUse);
2823
2824 // And the path is left alone: we could not identify it as a dead
2825 // daemon's socket, so it was never ours to delete.
2826 const st = try std.posix.fstatat(
2827 std.posix.AT.FDCWD,
2828 sock_path,
2829 std.posix.AT.SYMLINK_NOFOLLOW,
2830 );
2831 try std.testing.expect(std.posix.S.ISLNK(st.mode));
2832 }
2833
2834 test "Server: a non-socket at the path is refused, not deleted" {
2835 const alloc = std.testing.allocator;
2836
2837 var tmp = std.testing.tmpDir(.{});
2838 defer tmp.cleanup();
2839 var path_buf: [256]u8 = undefined;
2840 const dir_path = try tmp.dir.realpath(".", &path_buf);
2841 const file_path = try std.fmt.allocPrint(alloc, "{s}/notes.txt", .{dir_path});
2842 defer alloc.free(file_path);
2843
2844 const contents = "muxd must not eat this";
2845 try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents });
2846
2847 // Connecting to a regular file fails with ECONNREFUSED — the very same
2848 // errno a dead socket gives — so identifying stale sockets by the
2849 // connect result alone would delete this file. It is the stat that
2850 // saves it, and this test is what pins that.
2851 try expectInitRefused(alloc, file_path, error.SockPathNotASocket);
2852
2853 const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024);
2854 defer alloc.free(after);
2855 try std.testing.expectEqualStrings(contents, after);
2856 }
2857
2656 /// Test helper: the first snapshot-or-delta frame to arrive, reduced to what 2858 /// Test helper: the first snapshot-or-delta frame to arrive, reduced to what
2657 /// the resync tests assert on. `epoch` is 0 for a delta — only snapshots 2859 /// the resync tests assert on. `epoch` is 0 for a delta — only snapshots
2658 /// carry the session epoch. 2860 /// carry the session epoch.