b296da2b
fix: muxd run refuses a live socket instead of stealing it
a73x 2026-08-08 14:08
Commit message
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. |