a73x

57da0875

fix: clean daemon shutdown on SIGINT/SIGTERM; attach takeover; audible client exits

a73x   2026-08-08 14:08

Commit message
fix: clean daemon shutdown on SIGINT/SIGTERM; attach takeover; audible client exits

- Ctrl-C on a foreground muxd now removes the socket file and reaps the
  shell (the stale socket made every later mux/dump fail confusingly)
- a new attach takes over the session: old client receives taken_over and
  says so; self-heals clients that died without detaching (M5 replaces
  this with real multi-client)
- mux prints why it exited (detach, takeover, lost connection) after
  restoring the terminal instead of exiting silently

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