57da0875
fix: clean daemon shutdown on SIGINT/SIGTERM; attach takeover; audible client exits
a73x 2026-08-08 14:08
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -51,9 +51,16 @@ | |||
| 51 | structured payloads (damage regions, cell runs) first appear; every M2 | 51 | structured payloads (damage regions, cell runs) first appear; every M2 |
| 52 | payload is a byte blob or two u16s. Recorded so M4 doesn't inherit this | 52 | payload is a byte blob or two u16s. Recorded so M4 doesn't inherit this |
| 53 | by inertia. | 53 | by inertia. |
| 54 | - **Single interactive client + dump-only observers.** Second `attach` is | 54 | - **Single interactive client + dump-only observers.** Unattached |
| 55 | refused with exit_status{1}; unattached connections may `debug_dump` | 55 | connections may `debug_dump` (keeps `muxd dump` working alongside a live |
| 56 | (keeps `muxd dump` working alongside a live client). M5 replaces this. | 56 | client). A second `attach` *takes over*: the old client gets `taken_over` |
| 57 | and exits with a message. Chosen over refusal after real use showed | ||
| 58 | refusal exits silently and stale clients wedge the session. M5 replaces | ||
| 59 | this with real multi-client. | ||
| 60 | - **Daemon shuts down cleanly on SIGINT/SIGTERM** (removes socket file, | ||
| 61 | reaps shell). Found via first-touch use: Ctrl-C on a foreground `muxd | ||
| 62 | run` left a stale socket, making every later `mux`/`dump` fail with a | ||
| 63 | confusing connection error. | ||
| 57 | - **Blocking frame I/O.** One local client on a Unix socket; a stuck client | 64 | - **Blocking frame I/O.** One local client on a Unix socket; a stuck client |
| 58 | can stall the daemon. Buffered nonblocking I/O is owed by M4 (network). | 65 | can stall the daemon. Buffered nonblocking I/O is owed by M4 (network). |
| 59 | - **Detach chord: Ctrl-\ (0x1c).** No keybinding layer in the prototype. | 66 | - **Detach chord: Ctrl-\ (0x1c).** No keybinding layer in the prototype. |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -28,6 +28,11 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 28 | var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | 28 | var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); |
| 29 | defer replica.deinit(); | 29 | defer replica.deinit(); |
| 30 | 30 | ||
| 31 | // Registered before the terminal-restore defer so it runs after it: | ||
| 32 | // messages land on the normal screen, not the wiped alternate one. | ||
| 33 | var exit_msg: ?[]const u8 = null; | ||
| 34 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); | ||
| 35 | |||
| 31 | // Raw mode + alternate screen when we own a terminal. | 36 | // Raw mode + alternate screen when we own a terminal. |
| 32 | var orig_termios: ?std.posix.termios = null; | 37 | var orig_termios: ?std.posix.termios = null; |
| 33 | if (is_tty) { | 38 | if (is_tty) { |
| @@ -76,7 +81,10 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 76 | _ = try std.posix.poll(&fds, 100); | 81 | _ = try std.posix.poll(&fds, 100); |
| 77 | 82 | ||
| 78 | if (fds[0].revents != 0) { | 83 | if (fds[0].revents != 0) { |
| 79 | const frame = (try proto.readFrame(alloc, sock)) orelse return 1; | 84 | const frame = (try proto.readFrame(alloc, sock)) orelse { |
| 85 | exit_msg = "mux: connection to muxd lost"; | ||
| 86 | return 1; | ||
| 87 | }; | ||
| 80 | defer frame.deinit(alloc); | 88 | defer frame.deinit(alloc); |
| 81 | switch (frame.type) { | 89 | switch (frame.type) { |
| 82 | .snapshot => { | 90 | .snapshot => { |
| @@ -87,6 +95,10 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 87 | .exit_status => { | 95 | .exit_status => { |
| 88 | return if (frame.payload.len >= 1) frame.payload[0] else 0; | 96 | return if (frame.payload.len >= 1) frame.payload[0] else 0; |
| 89 | }, | 97 | }, |
| 98 | .taken_over => { | ||
| 99 | exit_msg = "mux: detached (another client attached)"; | ||
| 100 | return 0; | ||
| 101 | }, | ||
| 90 | else => {}, | 102 | else => {}, |
| 91 | } | 103 | } |
| 92 | } | 104 | } |
| @@ -99,6 +111,7 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 99 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { | 111 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { |
| 100 | // Ctrl-\: detach and leave the session running. | 112 | // Ctrl-\: detach and leave the session running. |
| 101 | proto.writeFrame(sock, .detach, "") catch {}; | 113 | proto.writeFrame(sock, .detach, "") catch {}; |
| 114 | exit_msg = "mux: detached (session still running; run mux to reattach)"; | ||
| 102 | return 0; | 115 | return 0; |
| 103 | } | 116 | } |
| 104 | try proto.writeFrame(sock, .input, buf[0..n]); | 117 | try proto.writeFrame(sock, .input, buf[0..n]); |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -72,6 +72,7 @@ pub fn main() !u8 { | |||
| 72 | .rows = rows, | 72 | .rows = rows, |
| 73 | }); | 73 | }); |
| 74 | defer srv.deinit(); | 74 | defer srv.deinit(); |
| 75 | @import("server").installSignalHandlers(); | ||
| 75 | return try srv.run(); | 76 | return try srv.run(); |
| 76 | } | 77 | } |
| 77 | if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); | 78 | if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); |
src/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ pub const MsgType = enum(u8) { | |||
| 14 | // daemon -> client | 14 | // daemon -> client |
| 15 | snapshot = 0x81, // payload: full-state vt dump (TerminalFormatter .all) | 15 | snapshot = 0x81, // payload: full-state vt dump (TerminalFormatter .all) |
| 16 | exit_status = 0x82, // payload: 1 byte exit code | 16 | exit_status = 0x82, // payload: 1 byte exit code |
| 17 | taken_over = 0x84, // payload: empty; a newer client attached, you're out | ||
| 17 | dump_reply = 0xff, // payload: requested dump bytes | 18 | dump_reply = 0xff, // payload: requested dump bytes |
| 18 | _, | 19 | _, |
| 19 | }; | 20 | }; |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,24 @@ const proto = @import("protocol"); | |||
| 9 | 9 | ||
| 10 | const max_observers = 4; | 10 | const max_observers = 4; |
| 11 | 11 | ||
| 12 | var shutdown_flag = std.atomic.Value(bool).init(false); | ||
| 13 | |||
| 14 | fn onShutdownSignal(_: c_int) callconv(.c) void { | ||
| 15 | shutdown_flag.store(true, .release); | ||
| 16 | } | ||
| 17 | |||
| 18 | /// Install SIGINT/SIGTERM handlers so a foreground `muxd run` shuts down | ||
| 19 | /// cleanly (socket file removed, shell reaped). Called by main, not tests. | ||
| 20 | pub fn installSignalHandlers() void { | ||
| 21 | var sa: std.posix.Sigaction = .{ | ||
| 22 | .handler = .{ .handler = onShutdownSignal }, | ||
| 23 | .mask = std.posix.sigemptyset(), | ||
| 24 | .flags = 0, | ||
| 25 | }; | ||
| 26 | std.posix.sigaction(std.posix.SIG.INT, &sa, null); | ||
| 27 | std.posix.sigaction(std.posix.SIG.TERM, &sa, null); | ||
| 28 | } | ||
| 29 | |||
| 12 | pub const Server = struct { | 30 | pub const Server = struct { |
| 13 | alloc: std.mem.Allocator, | 31 | alloc: std.mem.Allocator, |
| 14 | eng: *Engine, | 32 | eng: *Engine, |
| @@ -131,6 +149,7 @@ pub const Server = struct { | |||
| 131 | 149 | ||
| 132 | pub fn run(self: *Server) !u8 { | 150 | pub fn run(self: *Server) !u8 { |
| 133 | while (true) { | 151 | while (true) { |
| 152 | if (shutdown_flag.load(.acquire)) return 130; | ||
| 134 | if (try self.pumpOnce(100)) |code| return code; | 153 | if (try self.pumpOnce(100)) |code| return code; |
| 135 | } | 154 | } |
| 136 | } | 155 | } |
| @@ -193,16 +212,18 @@ pub const Server = struct { | |||
| 193 | 212 | ||
| 194 | switch (frame.type) { | 213 | switch (frame.type) { |
| 195 | .attach => { | 214 | .attach => { |
| 196 | if (self.client != null) { | ||
| 197 | // Busy: refuse this attacher. M5 replaces this policy. | ||
| 198 | proto.writeFrame(fd, .exit_status, &.{1}) catch {}; | ||
| 199 | self.dropObserver(i); | ||
| 200 | return; | ||
| 201 | } | ||
| 202 | const sz = proto.decodeSize(frame.payload) catch { | 215 | const sz = proto.decodeSize(frame.payload) catch { |
| 203 | self.dropObserver(i); | 216 | self.dropObserver(i); |
| 204 | return; | 217 | return; |
| 205 | }; | 218 | }; |
| 219 | // Takeover: the newest attacher wins. Self-heals stale | ||
| 220 | // clients that died without detaching. M5 replaces this | ||
| 221 | // with real multi-client. | ||
| 222 | if (self.client) |old| { | ||
| 223 | proto.writeFrame(old, .taken_over, "") catch {}; | ||
| 224 | std.posix.close(old); | ||
| 225 | self.client = null; | ||
| 226 | } | ||
| 206 | self.observers[i] = null; // promote without closing | 227 | self.observers[i] = null; // promote without closing |
| 207 | self.client = fd; | 228 | self.client = fd; |
| 208 | self.applySize(sz.cols, sz.rows); | 229 | self.applySize(sz.cols, sz.rows); |
| @@ -247,6 +268,65 @@ fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void { | |||
| 247 | } | 268 | } |
| 248 | } | 269 | } |
| 249 | 270 | ||
| 271 | test "Server: a new attach takes over; the old client is told" { | ||
| 272 | const alloc = std.testing.allocator; | ||
| 273 | |||
| 274 | var tmp = std.testing.tmpDir(.{}); | ||
| 275 | defer tmp.cleanup(); | ||
| 276 | var path_buf: [256]u8 = undefined; | ||
| 277 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 278 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/takeover.sock", .{dir_path}); | ||
| 279 | defer alloc.free(sock_path); | ||
| 280 | |||
| 281 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 282 | defer srv.deinit(); | ||
| 283 | |||
| 284 | var stop = std.atomic.Value(bool).init(false); | ||
| 285 | const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop }); | ||
| 286 | defer th.join(); | ||
| 287 | defer stop.store(true, .release); | ||
| 288 | |||
| 289 | const a = try std.net.connectUnixSocket(sock_path); | ||
| 290 | defer a.close(); | ||
| 291 | try proto.writeFrame(a.handle, .attach, &proto.encodeSize(80, 24)); | ||
| 292 | |||
| 293 | const b = try std.net.connectUnixSocket(sock_path); | ||
| 294 | defer b.close(); | ||
| 295 | try proto.writeFrame(b.handle, .attach, &proto.encodeSize(80, 24)); | ||
| 296 | |||
| 297 | // Client A must receive taken_over (possibly after snapshots). | ||
| 298 | var a_kicked = false; | ||
| 299 | var deadline_ms: u64 = 5000; | ||
| 300 | while (deadline_ms > 0 and !a_kicked) { | ||
| 301 | var pfd = [_]std.posix.pollfd{ | ||
| 302 | .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 303 | }; | ||
| 304 | const ready = try std.posix.poll(&pfd, 100); | ||
| 305 | deadline_ms -|= 100; | ||
| 306 | if (ready == 0) continue; | ||
| 307 | const frame = (try proto.readFrame(alloc, a.handle)) orelse break; | ||
| 308 | defer frame.deinit(alloc); | ||
| 309 | if (frame.type == .taken_over) a_kicked = true; | ||
| 310 | } | ||
| 311 | try std.testing.expect(a_kicked); | ||
| 312 | |||
| 313 | // Client B is now the attached client and receives snapshots. | ||
| 314 | var b_snapshot = false; | ||
| 315 | deadline_ms = 5000; | ||
| 316 | while (deadline_ms > 0 and !b_snapshot) { | ||
| 317 | var pfd = [_]std.posix.pollfd{ | ||
| 318 | .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 319 | }; | ||
| 320 | const ready = try std.posix.poll(&pfd, 100); | ||
| 321 | deadline_ms -|= 100; | ||
| 322 | if (ready == 0) continue; | ||
| 323 | const frame = (try proto.readFrame(alloc, b.handle)) orelse break; | ||
| 324 | defer frame.deinit(alloc); | ||
| 325 | if (frame.type == .snapshot) b_snapshot = true; | ||
| 326 | } | ||
| 327 | try std.testing.expect(b_snapshot); | ||
| 328 | } | ||
| 329 | |||
| 250 | test "Server: replica rebuilt from snapshots matches the authoritative grid" { | 330 | test "Server: replica rebuilt from snapshots matches the authoritative grid" { |
| 251 | const alloc = std.testing.allocator; | 331 | const alloc = std.testing.allocator; |
| 252 | 332 | ||