e68e5b9a
feat: daemon server core with snapshot broadcast; replica fidelity test passes
a73x 2026-08-08 14:08
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -31,12 +31,15 @@ pub fn build(b: *std.Build) void { | |||
| 31 | .link_libc = true, | 31 | .link_libc = true, |
| 32 | }); | 32 | }); |
| 33 | 33 | ||
| 34 | const debug_mod = b.createModule(.{ | 34 | const server_mod = b.createModule(.{ |
| 35 | .root_source_file = b.path("src/debug.zig"), | 35 | .root_source_file = b.path("src/server.zig"), |
| 36 | .target = target, | 36 | .target = target, |
| 37 | .optimize = optimize, | 37 | .optimize = optimize, |
| 38 | .link_libc = true, | ||
| 38 | }); | 39 | }); |
| 39 | debug_mod.addImport("engine", engine_mod); | 40 | server_mod.addImport("engine", engine_mod); |
| 41 | server_mod.addImport("pty", pty_mod); | ||
| 42 | server_mod.addImport("protocol", protocol_mod); | ||
| 40 | 43 | ||
| 41 | const exe_mod = b.createModule(.{ | 44 | const exe_mod = b.createModule(.{ |
| 42 | .root_source_file = b.path("src/main.zig"), | 45 | .root_source_file = b.path("src/main.zig"), |
| @@ -44,9 +47,8 @@ pub fn build(b: *std.Build) void { | |||
| 44 | .optimize = optimize, | 47 | .optimize = optimize, |
| 45 | .link_libc = true, | 48 | .link_libc = true, |
| 46 | }); | 49 | }); |
| 47 | exe_mod.addImport("engine", engine_mod); | 50 | exe_mod.addImport("server", server_mod); |
| 48 | exe_mod.addImport("pty", pty_mod); | 51 | exe_mod.addImport("protocol", protocol_mod); |
| 49 | exe_mod.addImport("debug", debug_mod); | ||
| 50 | 52 | ||
| 51 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); | 53 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); |
| 52 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe | 54 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe |
| @@ -56,7 +58,7 @@ pub fn build(b: *std.Build) void { | |||
| 56 | b.installArtifact(exe); | 58 | b.installArtifact(exe); |
| 57 | 59 | ||
| 58 | const test_step = b.step("test", "Run unit tests"); | 60 | const test_step = b.step("test", "Run unit tests"); |
| 59 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, debug_mod }) |mod| { | 61 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod }) |mod| { |
| 60 | const t = b.addTest(.{ .root_module = mod }); | 62 | const t = b.addTest(.{ .root_module = mod }); |
| 61 | t.use_llvm = true; | 63 | t.use_llvm = true; |
| 62 | t.use_lld = true; | 64 | t.use_lld = true; |
src/debug.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,95 +0,0 @@ | |||
| 1 | //! M1-only debug listener. One LF-terminated command per connection: | ||
| 2 | //! "dump plain" | "dump vt" | ||
| 3 | //! Reply is the raw payload, EOF-delimited. Replaced by the real | ||
| 4 | //! protocol in M2. | ||
| 5 | const std = @import("std"); | ||
| 6 | const Engine = @import("engine").Engine; | ||
| 7 | |||
| 8 | pub const DebugServer = struct { | ||
| 9 | server: std.net.Server, | ||
| 10 | path: []const u8, | ||
| 11 | |||
| 12 | pub fn init(path: []const u8) !DebugServer { | ||
| 13 | std.fs.cwd().deleteFile(path) catch {}; | ||
| 14 | const addr = try std.net.Address.initUnix(path); | ||
| 15 | return .{ .server = try addr.listen(.{}), .path = path }; | ||
| 16 | } | ||
| 17 | |||
| 18 | pub fn deinit(self: *DebugServer) void { | ||
| 19 | self.server.deinit(); | ||
| 20 | std.fs.cwd().deleteFile(self.path) catch {}; | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Pollable listener fd for the daemon's event loop. | ||
| 24 | pub fn fd(self: *const DebugServer) std.posix.fd_t { | ||
| 25 | return self.server.stream.handle; | ||
| 26 | } | ||
| 27 | |||
| 28 | /// Accept one connection, service it synchronously, close it. | ||
| 29 | /// Dumps are small and local; blocking here is fine for a debug tool. | ||
| 30 | pub fn serviceOne(self: *DebugServer, alloc: std.mem.Allocator, eng: *Engine) void { | ||
| 31 | const conn = self.server.accept() catch return; | ||
| 32 | defer conn.stream.close(); | ||
| 33 | |||
| 34 | var buf: [256]u8 = undefined; | ||
| 35 | const n = std.posix.read(conn.stream.handle, &buf) catch return; | ||
| 36 | const line = std.mem.trimRight(u8, buf[0..n], "\r\n"); | ||
| 37 | |||
| 38 | const reply: []const u8 = if (std.mem.eql(u8, line, "dump plain")) | ||
| 39 | eng.dumpPlain(alloc) catch return | ||
| 40 | else if (std.mem.eql(u8, line, "dump vt")) | ||
| 41 | eng.dumpVt(alloc) catch return | ||
| 42 | else | ||
| 43 | "error: unknown command (want: dump plain | dump vt)"; | ||
| 44 | const owned = !std.mem.startsWith(u8, reply, "error:"); | ||
| 45 | defer if (owned) alloc.free(reply); | ||
| 46 | |||
| 47 | var idx: usize = 0; | ||
| 48 | while (idx < reply.len) { | ||
| 49 | idx += std.posix.write(conn.stream.handle, reply[idx..]) catch return; | ||
| 50 | } | ||
| 51 | } | ||
| 52 | }; | ||
| 53 | |||
| 54 | test "DebugServer: dump plain round trip over unix socket" { | ||
| 55 | const alloc = std.testing.allocator; | ||
| 56 | |||
| 57 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 58 | defer e.deinit(); | ||
| 59 | e.feed("debug-sock-ok"); | ||
| 60 | |||
| 61 | var tmp = std.testing.tmpDir(.{}); | ||
| 62 | defer tmp.cleanup(); | ||
| 63 | var path_buf: [256]u8 = undefined; | ||
| 64 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 65 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/d.sock", .{dir_path}); | ||
| 66 | defer alloc.free(sock_path); | ||
| 67 | |||
| 68 | var srv = try DebugServer.init(sock_path); | ||
| 69 | defer srv.deinit(); | ||
| 70 | |||
| 71 | const Client = struct { | ||
| 72 | fn go(path: []const u8, out: *std.ArrayList(u8), a: std.mem.Allocator) !void { | ||
| 73 | const stream = try std.net.connectUnixSocket(path); | ||
| 74 | defer stream.close(); | ||
| 75 | var idx: usize = 0; | ||
| 76 | const msg = "dump plain\n"; | ||
| 77 | while (idx < msg.len) idx += try std.posix.write(stream.handle, msg[idx..]); | ||
| 78 | var buf: [4096]u8 = undefined; | ||
| 79 | while (true) { | ||
| 80 | const n = try std.posix.read(stream.handle, &buf); | ||
| 81 | if (n == 0) break; | ||
| 82 | try out.appendSlice(a, buf[0..n]); | ||
| 83 | } | ||
| 84 | } | ||
| 85 | }; | ||
| 86 | |||
| 87 | var reply: std.ArrayList(u8) = .empty; | ||
| 88 | defer reply.deinit(alloc); | ||
| 89 | const t = try std.Thread.spawn(.{}, Client.go, .{ sock_path, &reply, alloc }); | ||
| 90 | |||
| 91 | srv.serviceOne(alloc, e); // blocks in accept until the client connects | ||
| 92 | t.join(); | ||
| 93 | |||
| 94 | try std.testing.expectEqualStrings("debug-sock-ok", reply.items); | ||
| 95 | } | ||
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,181 +1 @@ | |||
| 1 | //! muxd — M1 prototype daemon. One session, foreground, single-threaded | 1 | pub fn main() !void {} |
| 2 | //! poll loop. `run` hosts $SHELL on a PTY feeding the headless engine; | ||
| 3 | //! `dump` prints the authoritative grid over the debug socket. | ||
| 4 | const std = @import("std"); | ||
| 5 | const Engine = @import("engine").Engine; | ||
| 6 | const Pty = @import("pty").Pty; | ||
| 7 | const debug = @import("debug"); | ||
| 8 | |||
| 9 | const usage = | ||
| 10 | \\usage: | ||
| 11 | \\ muxd run [--sock PATH] [--shell PATH] run daemon in foreground; | ||
| 12 | \\ stdin is forwarded to the PTY | ||
| 13 | \\ muxd dump [--vt] [--sock PATH] print the current grid | ||
| 14 | \\ | ||
| 15 | ; | ||
| 16 | |||
| 17 | pub fn main() !u8 { | ||
| 18 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 19 | defer _ = gpa.deinit(); | ||
| 20 | const alloc = gpa.allocator(); | ||
| 21 | |||
| 22 | const args = try std.process.argsAlloc(alloc); | ||
| 23 | defer std.process.argsFree(alloc, args); | ||
| 24 | |||
| 25 | if (args.len < 2) { | ||
| 26 | std.debug.print("{s}", .{usage}); | ||
| 27 | return 2; | ||
| 28 | } | ||
| 29 | |||
| 30 | var sock_arg: ?[]const u8 = null; | ||
| 31 | var shell_arg: ?[]const u8 = null; | ||
| 32 | var vt_mode = false; | ||
| 33 | var i: usize = 2; | ||
| 34 | while (i < args.len) : (i += 1) { | ||
| 35 | const a = args[i]; | ||
| 36 | if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 37 | i += 1; | ||
| 38 | sock_arg = args[i]; | ||
| 39 | } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) { | ||
| 40 | i += 1; | ||
| 41 | shell_arg = args[i]; | ||
| 42 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 43 | vt_mode = true; | ||
| 44 | } else { | ||
| 45 | std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }); | ||
| 46 | return 2; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | const sock_path = if (sock_arg) |s| | ||
| 51 | try alloc.dupe(u8, s) | ||
| 52 | else | ||
| 53 | try defaultSockPath(alloc); | ||
| 54 | defer alloc.free(sock_path); | ||
| 55 | |||
| 56 | if (std.mem.eql(u8, args[1], "run")) return run(alloc, sock_path, shell_arg); | ||
| 57 | if (std.mem.eql(u8, args[1], "dump")) return dump(sock_path, vt_mode); | ||
| 58 | std.debug.print("{s}", .{usage}); | ||
| 59 | return 2; | ||
| 60 | } | ||
| 61 | |||
| 62 | fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 63 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| { | ||
| 64 | return std.fmt.allocPrint(alloc, "{s}/muxd-debug.sock", .{dir}); | ||
| 65 | } | ||
| 66 | return std.fmt.allocPrint(alloc, "/tmp/muxd-debug-{d}.sock", .{std.os.linux.getuid()}); | ||
| 67 | } | ||
| 68 | |||
| 69 | fn run(alloc: std.mem.Allocator, sock_path: []const u8, shell_arg: ?[]const u8) !u8 { | ||
| 70 | const shell_z: [:0]const u8 = if (shell_arg) |s| | ||
| 71 | try alloc.dupeZ(u8, s) | ||
| 72 | else | ||
| 73 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | ||
| 74 | defer alloc.free(shell_z); | ||
| 75 | |||
| 76 | // Grid size: the controlling tty's size if we have one, else 80x24. | ||
| 77 | var cols: u16 = 80; | ||
| 78 | var rows: u16 = 24; | ||
| 79 | if (std.posix.isatty(std.posix.STDIN_FILENO)) { | ||
| 80 | var ws: std.posix.winsize = undefined; | ||
| 81 | if (std.os.linux.ioctl( | ||
| 82 | std.posix.STDIN_FILENO, | ||
| 83 | std.os.linux.T.IOCGWINSZ, | ||
| 84 | @intFromPtr(&ws), | ||
| 85 | ) == 0) { | ||
| 86 | cols = ws.col; | ||
| 87 | rows = ws.row; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }); | ||
| 92 | defer eng.deinit(); | ||
| 93 | |||
| 94 | var pty = try Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell_z }); | ||
| 95 | defer pty.deinit(); | ||
| 96 | |||
| 97 | var srv = try debug.DebugServer.init(sock_path); | ||
| 98 | defer srv.deinit(); | ||
| 99 | |||
| 100 | // Raw mode so keystrokes (arrows, ^C) pass through to the PTY. | ||
| 101 | const stdin_fd = std.posix.STDIN_FILENO; | ||
| 102 | var orig_termios: ?std.posix.termios = null; | ||
| 103 | if (std.posix.isatty(stdin_fd)) { | ||
| 104 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 105 | orig_termios = orig; | ||
| 106 | var raw = orig; | ||
| 107 | raw.lflag.ICANON = false; | ||
| 108 | raw.lflag.ECHO = false; | ||
| 109 | raw.lflag.ISIG = false; | ||
| 110 | raw.iflag.IXON = false; | ||
| 111 | raw.iflag.ICRNL = false; | ||
| 112 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 113 | } | ||
| 114 | defer if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 115 | |||
| 116 | var stdin_open = true; | ||
| 117 | var buf: [64 * 1024]u8 = undefined; | ||
| 118 | while (true) { | ||
| 119 | if (pty.checkExited()) |code| return @intCast(code & 0xff); | ||
| 120 | |||
| 121 | var fds = [_]std.posix.pollfd{ | ||
| 122 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 123 | .{ .fd = srv.fd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 124 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 125 | }; | ||
| 126 | // 100ms timeout so child exit is noticed even with no fd activity. | ||
| 127 | // (std.posix.poll retries EINTR internally.) | ||
| 128 | _ = try std.posix.poll(&fds, 100); | ||
| 129 | |||
| 130 | if (fds[0].revents & std.posix.POLL.IN != 0) { | ||
| 131 | const n = std.posix.read(pty.master, &buf) catch 0; | ||
| 132 | if (n > 0) { | ||
| 133 | eng.feed(buf[0..n]); | ||
| 134 | const resp = eng.ptyOutput(); | ||
| 135 | if (resp.len > 0) { | ||
| 136 | writeAll(pty.master, resp); | ||
| 137 | eng.clearPtyOutput(); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | if (fds[1].revents & std.posix.POLL.IN != 0) srv.serviceOne(alloc, eng); | ||
| 143 | |||
| 144 | // A pipe/FIFO at EOF reports POLLHUP without POLLIN; treat any | ||
| 145 | // revents as "try a read" or an idle stdin busy-loops the daemon. | ||
| 146 | if (stdin_open and fds[2].revents != 0) { | ||
| 147 | const n = std.posix.read(stdin_fd, &buf) catch 0; | ||
| 148 | if (n == 0) { | ||
| 149 | stdin_open = false; // stdin closed; keep running headless | ||
| 150 | } else { | ||
| 151 | writeAll(pty.master, buf[0..n]); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | fn dump(sock_path: []const u8, vt_mode: bool) !u8 { | ||
| 158 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 159 | std.debug.print("muxd dump: cannot connect to {s} (is `muxd run` running?)\n", .{sock_path}); | ||
| 160 | return 1; | ||
| 161 | }; | ||
| 162 | defer stream.close(); | ||
| 163 | |||
| 164 | writeAll(stream.handle, if (vt_mode) "dump vt\n" else "dump plain\n"); | ||
| 165 | |||
| 166 | var buf: [4096]u8 = undefined; | ||
| 167 | while (true) { | ||
| 168 | const n = std.posix.read(stream.handle, &buf) catch break; | ||
| 169 | if (n == 0) break; | ||
| 170 | writeAll(std.posix.STDOUT_FILENO, buf[0..n]); | ||
| 171 | } | ||
| 172 | writeAll(std.posix.STDOUT_FILENO, "\n"); | ||
| 173 | return 0; | ||
| 174 | } | ||
| 175 | |||
| 176 | fn writeAll(fd: std.posix.fd_t, data: []const u8) void { | ||
| 177 | var idx: usize = 0; | ||
| 178 | while (idx < data.len) { | ||
| 179 | idx += std.posix.write(fd, data[idx..]) catch return; | ||
| 180 | } | ||
| 181 | } | ||
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,317 @@ | |||
| 1 | //! muxd's daemon core: one session (engine + pty), one listener, at most | ||
| 2 | //! one attached interactive client (M5 lifts this) plus a few dump-only | ||
| 3 | //! observer connections. Single-threaded; pumpOnce is one poll iteration | ||
| 4 | //! so tests can drive the loop. | ||
| 5 | const std = @import("std"); | ||
| 6 | const Engine = @import("engine").Engine; | ||
| 7 | const Pty = @import("pty").Pty; | ||
| 8 | const proto = @import("protocol"); | ||
| 9 | |||
| 10 | const max_observers = 4; | ||
| 11 | |||
| 12 | pub const Server = struct { | ||
| 13 | alloc: std.mem.Allocator, | ||
| 14 | eng: *Engine, | ||
| 15 | pty: Pty, | ||
| 16 | listener: std.net.Server, | ||
| 17 | sock_path: []const u8, | ||
| 18 | owns_sock_file: bool, | ||
| 19 | /// The attached interactive client, if any. | ||
| 20 | client: ?std.posix.fd_t = null, | ||
| 21 | /// Connections that haven't attached (muxd dump, or a client waiting | ||
| 22 | /// to attach). May send debug_dump; attach promotes to `client`. | ||
| 23 | observers: [max_observers]?std.posix.fd_t = @splat(null), | ||
| 24 | |||
| 25 | pub const Options = struct { | ||
| 26 | sock_path: []const u8, | ||
| 27 | shell: [:0]const u8, | ||
| 28 | cols: u16 = 80, | ||
| 29 | rows: u16 = 24, | ||
| 30 | }; | ||
| 31 | |||
| 32 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | ||
| 33 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); | ||
| 34 | errdefer eng.deinit(); | ||
| 35 | |||
| 36 | var pty = try Pty.spawn(.{ .cols = opts.cols, .rows = opts.rows, .shell = opts.shell }); | ||
| 37 | errdefer pty.deinit(); | ||
| 38 | |||
| 39 | // systemd socket activation: LISTEN_FDS=1 hands us the listener as fd 3. | ||
| 40 | if (listenFdFromSystemd()) |fd| { | ||
| 41 | return .{ | ||
| 42 | .alloc = alloc, | ||
| 43 | .eng = eng, | ||
| 44 | .pty = pty, | ||
| 45 | .listener = .{ | ||
| 46 | .listen_address = undefined, | ||
| 47 | .stream = .{ .handle = fd }, | ||
| 48 | }, | ||
| 49 | .sock_path = opts.sock_path, | ||
| 50 | .owns_sock_file = false, | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | std.fs.cwd().deleteFile(opts.sock_path) catch {}; | ||
| 55 | const addr = try std.net.Address.initUnix(opts.sock_path); | ||
| 56 | return .{ | ||
| 57 | .alloc = alloc, | ||
| 58 | .eng = eng, | ||
| 59 | .pty = pty, | ||
| 60 | .listener = try addr.listen(.{}), | ||
| 61 | .sock_path = opts.sock_path, | ||
| 62 | .owns_sock_file = true, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | fn listenFdFromSystemd() ?std.posix.fd_t { | ||
| 67 | const pid_s = std.posix.getenv("LISTEN_PID") orelse return null; | ||
| 68 | const nfds_s = std.posix.getenv("LISTEN_FDS") orelse return null; | ||
| 69 | const pid = std.fmt.parseInt(std.posix.pid_t, pid_s, 10) catch return null; | ||
| 70 | const nfds = std.fmt.parseInt(u32, nfds_s, 10) catch return null; | ||
| 71 | if (pid != std.os.linux.getpid() or nfds < 1) return null; | ||
| 72 | return 3; // SD_LISTEN_FDS_START | ||
| 73 | } | ||
| 74 | |||
| 75 | pub fn deinit(self: *Server) void { | ||
| 76 | if (self.client) |fd| std.posix.close(fd); | ||
| 77 | for (self.observers) |slot| { | ||
| 78 | if (slot) |fd| std.posix.close(fd); | ||
| 79 | } | ||
| 80 | self.listener.deinit(); | ||
| 81 | if (self.owns_sock_file) std.fs.cwd().deleteFile(self.sock_path) catch {}; | ||
| 82 | self.pty.deinit(); | ||
| 83 | self.eng.deinit(); | ||
| 84 | } | ||
| 85 | |||
| 86 | /// One poll iteration. Returns the shell's exit code once it exits, | ||
| 87 | /// null while the session lives. | ||
| 88 | pub fn pumpOnce(self: *Server, timeout_ms: i32) !?u8 { | ||
| 89 | if (self.pty.checkExited()) |code| { | ||
| 90 | if (self.client) |fd| { | ||
| 91 | proto.writeFrame(fd, .exit_status, &.{@intCast(code & 0xff)}) catch {}; | ||
| 92 | } | ||
| 93 | return @intCast(code & 0xff); | ||
| 94 | } | ||
| 95 | |||
| 96 | var fds: [3 + max_observers]std.posix.pollfd = undefined; | ||
| 97 | fds[0] = .{ .fd = self.pty.master, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 98 | fds[1] = .{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 99 | fds[2] = .{ .fd = self.client orelse -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 100 | for (self.observers, 0..) |slot, i| { | ||
| 101 | fds[3 + i] = .{ .fd = slot orelse -1, .events = std.posix.POLL.IN, .revents = 0 }; | ||
| 102 | } | ||
| 103 | _ = try std.posix.poll(&fds, timeout_ms); | ||
| 104 | |||
| 105 | if (fds[0].revents != 0) { | ||
| 106 | var buf: [64 * 1024]u8 = undefined; | ||
| 107 | const n = std.posix.read(self.pty.master, &buf) catch 0; | ||
| 108 | if (n > 0) { | ||
| 109 | self.eng.feed(buf[0..n]); | ||
| 110 | const resp = self.eng.ptyOutput(); | ||
| 111 | if (resp.len > 0) { | ||
| 112 | proto.writeAllFd(self.pty.master, resp) catch {}; | ||
| 113 | self.eng.clearPtyOutput(); | ||
| 114 | } | ||
| 115 | self.sendSnapshot(); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | if (fds[1].revents & std.posix.POLL.IN != 0) self.acceptConn(); | ||
| 120 | |||
| 121 | if (self.client != null and fds[2].revents != 0) self.serviceClient(); | ||
| 122 | |||
| 123 | for (0..max_observers) |i| { | ||
| 124 | if (self.observers[i] != null and fds[3 + i].revents != 0) { | ||
| 125 | self.serviceObserver(i); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | return null; | ||
| 130 | } | ||
| 131 | |||
| 132 | pub fn run(self: *Server) !u8 { | ||
| 133 | while (true) { | ||
| 134 | if (try self.pumpOnce(100)) |code| return code; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | fn acceptConn(self: *Server) void { | ||
| 139 | const conn = self.listener.accept() catch return; | ||
| 140 | for (&self.observers) |*slot| { | ||
| 141 | if (slot.* == null) { | ||
| 142 | slot.* = conn.stream.handle; | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | } | ||
| 146 | conn.stream.close(); // out of slots | ||
| 147 | } | ||
| 148 | |||
| 149 | fn dropClient(self: *Server) void { | ||
| 150 | if (self.client) |fd| std.posix.close(fd); | ||
| 151 | self.client = null; | ||
| 152 | } | ||
| 153 | |||
| 154 | fn dropObserver(self: *Server, i: usize) void { | ||
| 155 | if (self.observers[i]) |fd| std.posix.close(fd); | ||
| 156 | self.observers[i] = null; | ||
| 157 | } | ||
| 158 | |||
| 159 | fn serviceClient(self: *Server) void { | ||
| 160 | const fd = self.client.?; | ||
| 161 | const frame = proto.readFrame(self.alloc, fd) catch { | ||
| 162 | self.dropClient(); | ||
| 163 | return; | ||
| 164 | } orelse { | ||
| 165 | self.dropClient(); | ||
| 166 | return; | ||
| 167 | }; | ||
| 168 | defer frame.deinit(self.alloc); | ||
| 169 | |||
| 170 | switch (frame.type) { | ||
| 171 | .attach, .resize => { | ||
| 172 | const sz = proto.decodeSize(frame.payload) catch return; | ||
| 173 | self.applySize(sz.cols, sz.rows); | ||
| 174 | self.sendSnapshot(); | ||
| 175 | }, | ||
| 176 | .input => proto.writeAllFd(self.pty.master, frame.payload) catch self.dropClient(), | ||
| 177 | .detach => self.dropClient(), | ||
| 178 | .debug_dump => self.replyDump(fd, frame.payload) catch self.dropClient(), | ||
| 179 | else => {}, | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | fn serviceObserver(self: *Server, i: usize) void { | ||
| 184 | const fd = self.observers[i].?; | ||
| 185 | const frame = proto.readFrame(self.alloc, fd) catch { | ||
| 186 | self.dropObserver(i); | ||
| 187 | return; | ||
| 188 | } orelse { | ||
| 189 | self.dropObserver(i); | ||
| 190 | return; | ||
| 191 | }; | ||
| 192 | defer frame.deinit(self.alloc); | ||
| 193 | |||
| 194 | switch (frame.type) { | ||
| 195 | .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 { | ||
| 203 | self.dropObserver(i); | ||
| 204 | return; | ||
| 205 | }; | ||
| 206 | self.observers[i] = null; // promote without closing | ||
| 207 | self.client = fd; | ||
| 208 | self.applySize(sz.cols, sz.rows); | ||
| 209 | self.sendSnapshot(); | ||
| 210 | }, | ||
| 211 | .debug_dump => self.replyDump(fd, frame.payload) catch self.dropObserver(i), | ||
| 212 | .detach => self.dropObserver(i), | ||
| 213 | else => {}, | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | fn replyDump(self: *Server, fd: std.posix.fd_t, payload: []const u8) !void { | ||
| 218 | const want_vt = payload.len >= 1 and payload[0] == 1; | ||
| 219 | const dump = if (want_vt) | ||
| 220 | try self.eng.dumpVt(self.alloc) | ||
| 221 | else | ||
| 222 | try self.eng.dumpPlain(self.alloc); | ||
| 223 | defer self.alloc.free(dump); | ||
| 224 | try proto.writeFrame(fd, .dump_reply, dump); | ||
| 225 | } | ||
| 226 | |||
| 227 | fn applySize(self: *Server, cols: u16, rows: u16) void { | ||
| 228 | self.eng.resize(cols, rows) catch return; | ||
| 229 | self.pty.resize(cols, rows) catch {}; | ||
| 230 | } | ||
| 231 | |||
| 232 | fn sendSnapshot(self: *Server) void { | ||
| 233 | const fd = self.client orelse return; | ||
| 234 | const state = self.eng.dumpState(self.alloc) catch return; | ||
| 235 | defer self.alloc.free(state); | ||
| 236 | proto.writeFrame(fd, .snapshot, state) catch self.dropClient(); | ||
| 237 | } | ||
| 238 | }; | ||
| 239 | |||
| 240 | fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void { | ||
| 241 | while (!stop.load(.acquire)) { | ||
| 242 | const code = srv.pumpOnce(50) catch break; | ||
| 243 | if (code != null) break; | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | test "Server: replica rebuilt from snapshots matches the authoritative grid" { | ||
| 248 | const alloc = std.testing.allocator; | ||
| 249 | |||
| 250 | var tmp = std.testing.tmpDir(.{}); | ||
| 251 | defer tmp.cleanup(); | ||
| 252 | var path_buf: [256]u8 = undefined; | ||
| 253 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 254 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/m2.sock", .{dir_path}); | ||
| 255 | defer alloc.free(sock_path); | ||
| 256 | |||
| 257 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 258 | defer srv.deinit(); | ||
| 259 | |||
| 260 | var stop = std.atomic.Value(bool).init(false); | ||
| 261 | const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop }); | ||
| 262 | defer th.join(); | ||
| 263 | defer stop.store(true, .release); | ||
| 264 | |||
| 265 | const stream = try std.net.connectUnixSocket(sock_path); | ||
| 266 | defer stream.close(); | ||
| 267 | const fd = stream.handle; | ||
| 268 | |||
| 269 | var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 }); | ||
| 270 | defer replica.deinit(); | ||
| 271 | |||
| 272 | try proto.writeFrame(fd, .attach, &proto.encodeSize(100, 30)); | ||
| 273 | try proto.writeFrame(fd, .input, "printf 'fidelity-%s\\n' ok\n"); | ||
| 274 | |||
| 275 | // Consume snapshots until the replica shows the command output. | ||
| 276 | var deadline_ms: u64 = 10_000; | ||
| 277 | var converged = false; | ||
| 278 | while (deadline_ms > 0 and !converged) { | ||
| 279 | var pfd = [_]std.posix.pollfd{ | ||
| 280 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 281 | }; | ||
| 282 | const ready = try std.posix.poll(&pfd, 100); | ||
| 283 | deadline_ms -|= 100; | ||
| 284 | if (ready == 0) continue; | ||
| 285 | const frame = (try proto.readFrame(alloc, fd)) orelse break; | ||
| 286 | defer frame.deinit(alloc); | ||
| 287 | if (frame.type != .snapshot) continue; | ||
| 288 | replica.reset(); | ||
| 289 | replica.feed(frame.payload); | ||
| 290 | const plain = try replica.dumpPlain(alloc); | ||
| 291 | defer alloc.free(plain); | ||
| 292 | if (std.mem.indexOf(u8, plain, "fidelity-ok") != null) converged = true; | ||
| 293 | } | ||
| 294 | try std.testing.expect(converged); | ||
| 295 | |||
| 296 | // Byte-compare replica vs authoritative daemon grid. | ||
| 297 | try proto.writeFrame(fd, .debug_dump, &.{1}); | ||
| 298 | var daemon_vt: ?[]u8 = null; | ||
| 299 | defer if (daemon_vt) |d| alloc.free(d); | ||
| 300 | while (daemon_vt == null) { | ||
| 301 | const frame = (try proto.readFrame(alloc, fd)) orelse break; | ||
| 302 | if (frame.type == .dump_reply) { | ||
| 303 | daemon_vt = frame.payload; // ownership taken | ||
| 304 | } else { | ||
| 305 | // Late snapshots may arrive before the reply; apply them so the | ||
| 306 | // replica stays current with what the dump will show. | ||
| 307 | if (frame.type == .snapshot) { | ||
| 308 | replica.reset(); | ||
| 309 | replica.feed(frame.payload); | ||
| 310 | } | ||
| 311 | frame.deinit(alloc); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | const replica_vt = try replica.dumpVt(alloc); | ||
| 315 | defer alloc.free(replica_vt); | ||
| 316 | try std.testing.expectEqualStrings(daemon_vt.?, replica_vt); | ||
| 317 | } | ||