7984d06e
feat: mux client with replica grid, renderer, resize, Ctrl-\ detach
a73x 2026-08-08 14:08
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,23 @@ pub fn build(b: *std.Build) void { | |||
| 41 | server_mod.addImport("pty", pty_mod); | 41 | server_mod.addImport("pty", pty_mod); |
| 42 | server_mod.addImport("protocol", protocol_mod); | 42 | server_mod.addImport("protocol", protocol_mod); |
| 43 | 43 | ||
| 44 | const client_mod = b.createModule(.{ | ||
| 45 | .root_source_file = b.path("src/client.zig"), | ||
| 46 | .target = target, | ||
| 47 | .optimize = optimize, | ||
| 48 | .link_libc = true, | ||
| 49 | }); | ||
| 50 | client_mod.addImport("engine", engine_mod); | ||
| 51 | client_mod.addImport("protocol", protocol_mod); | ||
| 52 | |||
| 53 | const mux_mod = b.createModule(.{ | ||
| 54 | .root_source_file = b.path("src/mux_main.zig"), | ||
| 55 | .target = target, | ||
| 56 | .optimize = optimize, | ||
| 57 | .link_libc = true, | ||
| 58 | }); | ||
| 59 | mux_mod.addImport("client", client_mod); | ||
| 60 | |||
| 44 | const exe_mod = b.createModule(.{ | 61 | const exe_mod = b.createModule(.{ |
| 45 | .root_source_file = b.path("src/main.zig"), | 62 | .root_source_file = b.path("src/main.zig"), |
| 46 | .target = target, | 63 | .target = target, |
| @@ -57,8 +74,13 @@ pub fn build(b: *std.Build) void { | |||
| 57 | exe.use_lld = true; | 74 | exe.use_lld = true; |
| 58 | b.installArtifact(exe); | 75 | b.installArtifact(exe); |
| 59 | 76 | ||
| 77 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); | ||
| 78 | mux_exe.use_llvm = true; | ||
| 79 | mux_exe.use_lld = true; | ||
| 80 | b.installArtifact(mux_exe); | ||
| 81 | |||
| 60 | const test_step = b.step("test", "Run unit tests"); | 82 | const test_step = b.step("test", "Run unit tests"); |
| 61 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod }) |mod| { | 83 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod }) |mod| { |
| 62 | const t = b.addTest(.{ .root_module = mod }); | 84 | const t = b.addTest(.{ .root_module = mod }); |
| 63 | t.use_llvm = true; | 85 | t.use_llvm = true; |
| 64 | t.use_lld = true; | 86 | t.use_lld = true; |
| @@ -67,6 +89,7 @@ pub fn build(b: *std.Build) void { | |||
| 67 | 89 | ||
| 68 | const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); | 90 | const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); |
| 69 | e2e.addArtifactArg(exe); | 91 | e2e.addArtifactArg(exe); |
| 92 | e2e.addArtifactArg(mux_exe); | ||
| 70 | const e2e_step = b.step("e2e", "Run end-to-end test"); | 93 | const e2e_step = b.step("e2e", "Run end-to-end test"); |
| 71 | e2e_step.dependOn(&e2e.step); | 94 | e2e_step.dependOn(&e2e.step); |
| 72 | } | 95 | } |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,160 @@ | |||
| 1 | //! mux client: connects, attaches, maintains a replica engine rebuilt | ||
| 2 | //! from snapshots, repaints the local terminal, forwards keystrokes. | ||
| 3 | //! Detach chord: Ctrl-\ (0x1c). No keybinding layer in this prototype. | ||
| 4 | const std = @import("std"); | ||
| 5 | const Engine = @import("engine").Engine; | ||
| 6 | const proto = @import("protocol"); | ||
| 7 | |||
| 8 | var winch_flag = std.atomic.Value(bool).init(false); | ||
| 9 | |||
| 10 | fn onWinch(_: c_int) callconv(.c) void { | ||
| 11 | winch_flag.store(true, .release); | ||
| 12 | } | ||
| 13 | |||
| 14 | pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 15 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 16 | std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path}); | ||
| 17 | return 1; | ||
| 18 | }; | ||
| 19 | defer stream.close(); | ||
| 20 | const sock = stream.handle; | ||
| 21 | |||
| 22 | const stdin_fd = std.posix.STDIN_FILENO; | ||
| 23 | const stdout_fd = std.posix.STDOUT_FILENO; | ||
| 24 | const is_tty = std.posix.isatty(stdin_fd); | ||
| 25 | |||
| 26 | var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; | ||
| 27 | |||
| 28 | var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | ||
| 29 | defer replica.deinit(); | ||
| 30 | |||
| 31 | // Raw mode + alternate screen when we own a terminal. | ||
| 32 | var orig_termios: ?std.posix.termios = null; | ||
| 33 | if (is_tty) { | ||
| 34 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 35 | orig_termios = orig; | ||
| 36 | var raw = orig; | ||
| 37 | raw.lflag.ICANON = false; | ||
| 38 | raw.lflag.ECHO = false; | ||
| 39 | raw.lflag.ISIG = false; | ||
| 40 | raw.iflag.IXON = false; | ||
| 41 | raw.iflag.ICRNL = false; | ||
| 42 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 43 | try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l"); | ||
| 44 | |||
| 45 | var sa: std.posix.Sigaction = .{ | ||
| 46 | .handler = .{ .handler = onWinch }, | ||
| 47 | .mask = std.posix.sigemptyset(), | ||
| 48 | .flags = 0, | ||
| 49 | }; | ||
| 50 | std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); | ||
| 51 | } | ||
| 52 | defer if (orig_termios) |t| { | ||
| 53 | proto.writeAllFd(stdout_fd, "\x1b[?25h\x1b[?1049l") catch {}; | ||
| 54 | std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 55 | }; | ||
| 56 | |||
| 57 | try proto.writeFrame(sock, .attach, &proto.encodeSize(size.cols, size.rows)); | ||
| 58 | |||
| 59 | var stdin_open = true; | ||
| 60 | var buf: [16 * 1024]u8 = undefined; | ||
| 61 | while (true) { | ||
| 62 | if (winch_flag.swap(false, .acq_rel)) { | ||
| 63 | if (ttySize(stdout_fd)) |new_size| { | ||
| 64 | if (new_size.cols != size.cols or new_size.rows != size.rows) { | ||
| 65 | size = new_size; | ||
| 66 | try replica.resize(size.cols, size.rows); | ||
| 67 | try proto.writeFrame(sock, .resize, &proto.encodeSize(size.cols, size.rows)); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | var fds = [_]std.posix.pollfd{ | ||
| 73 | .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 74 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 75 | }; | ||
| 76 | _ = try std.posix.poll(&fds, 100); | ||
| 77 | |||
| 78 | if (fds[0].revents != 0) { | ||
| 79 | const frame = (try proto.readFrame(alloc, sock)) orelse return 1; | ||
| 80 | defer frame.deinit(alloc); | ||
| 81 | switch (frame.type) { | ||
| 82 | .snapshot => { | ||
| 83 | replica.reset(); | ||
| 84 | replica.feed(frame.payload); | ||
| 85 | try render(alloc, replica, stdout_fd); | ||
| 86 | }, | ||
| 87 | .exit_status => { | ||
| 88 | return if (frame.payload.len >= 1) frame.payload[0] else 0; | ||
| 89 | }, | ||
| 90 | else => {}, | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | if (stdin_open and fds[1].revents != 0) { | ||
| 95 | const n = std.posix.read(stdin_fd, &buf) catch 0; | ||
| 96 | if (n == 0) { | ||
| 97 | stdin_open = false; | ||
| 98 | } else { | ||
| 99 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { | ||
| 100 | // Ctrl-\: detach and leave the session running. | ||
| 101 | proto.writeFrame(sock, .detach, "") catch {}; | ||
| 102 | return 0; | ||
| 103 | } | ||
| 104 | try proto.writeFrame(sock, .input, buf[0..n]); | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | fn ttySize(fd: std.posix.fd_t) ?proto.Size { | ||
| 111 | if (!std.posix.isatty(fd)) return null; | ||
| 112 | var ws: std.posix.winsize = undefined; | ||
| 113 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 114 | return .{ .cols = ws.col, .rows = ws.row }; | ||
| 115 | } | ||
| 116 | |||
| 117 | /// Repaint the whole replica: styled dump + cursor, bracketed by | ||
| 118 | /// synchronized output so capable terminals apply it atomically. | ||
| 119 | fn render(alloc: std.mem.Allocator, replica: *Engine, out_fd: std.posix.fd_t) !void { | ||
| 120 | var paint: std.ArrayList(u8) = .empty; | ||
| 121 | defer paint.deinit(alloc); | ||
| 122 | |||
| 123 | try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J"); | ||
| 124 | const styled = try replica.dumpVt(alloc); | ||
| 125 | defer alloc.free(styled); | ||
| 126 | try paint.appendSlice(alloc, styled); | ||
| 127 | |||
| 128 | const cur = replica.cursorPos(); | ||
| 129 | var cup_buf: [32]u8 = undefined; | ||
| 130 | const cup = try std.fmt.bufPrint(&cup_buf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }); | ||
| 131 | try paint.appendSlice(alloc, cup); | ||
| 132 | |||
| 133 | try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l"); | ||
| 134 | try proto.writeAllFd(out_fd, paint.items); | ||
| 135 | } | ||
| 136 | |||
| 137 | test "render paints replica content with cursor restore" { | ||
| 138 | const alloc = std.testing.allocator; | ||
| 139 | var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 140 | defer replica.deinit(); | ||
| 141 | replica.feed("painted\x1b[3;7H"); | ||
| 142 | |||
| 143 | const pipe = try std.posix.pipe(); | ||
| 144 | defer std.posix.close(pipe[0]); | ||
| 145 | try render(alloc, replica, pipe[1]); | ||
| 146 | std.posix.close(pipe[1]); | ||
| 147 | |||
| 148 | var out: std.ArrayList(u8) = .empty; | ||
| 149 | defer out.deinit(alloc); | ||
| 150 | var chunk: [4096]u8 = undefined; | ||
| 151 | while (true) { | ||
| 152 | const n = try std.posix.read(pipe[0], &chunk); | ||
| 153 | if (n == 0) break; | ||
| 154 | try out.appendSlice(alloc, chunk[0..n]); | ||
| 155 | } | ||
| 156 | |||
| 157 | try std.testing.expect(std.mem.indexOf(u8, out.items, "painted") != null); | ||
| 158 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[3;7H") != null); // cursor | ||
| 159 | try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?2026h") != null); // sync | ||
| 160 | } | ||
src/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -65,11 +65,14 @@ pub const Engine = struct { | |||
| 65 | return self.term.plainString(alloc); | 65 | return self.term.plainString(alloc); |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | /// Visible screen with SGR/style sequences preserved. Caller frees. | 68 | /// Visible screen with SGR/style sequences preserved — content only, |
| 69 | /// no palette/mode side effects, so it is safe to paint onto a host | ||
| 70 | /// terminal (the client renderer) without clobbering its theme. | ||
| 69 | pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { | 71 | pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { |
| 70 | var aw: std.Io.Writer.Allocating = .init(alloc); | 72 | var aw: std.Io.Writer.Allocating = .init(alloc); |
| 71 | defer aw.deinit(); | 73 | defer aw.deinit(); |
| 72 | const f = vt.formatter.TerminalFormatter.init(&self.term, .vt); | 74 | var f = vt.formatter.TerminalFormatter.init(&self.term, .vt); |
| 75 | f.extra = .none; | ||
| 73 | try f.format(&aw.writer); | 76 | try f.format(&aw.writer); |
| 74 | return try aw.toOwnedSlice(); | 77 | return try aw.toOwnedSlice(); |
| 75 | } | 78 | } |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,36 @@ | |||
| 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd. | ||
| 2 | const std = @import("std"); | ||
| 3 | const client = @import("client"); | ||
| 4 | |||
| 5 | const usage = "usage: mux [--sock PATH]\n"; | ||
| 6 | |||
| 7 | pub fn main() !u8 { | ||
| 8 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 9 | defer _ = gpa.deinit(); | ||
| 10 | const alloc = gpa.allocator(); | ||
| 11 | |||
| 12 | const args = try std.process.argsAlloc(alloc); | ||
| 13 | defer std.process.argsFree(alloc, args); | ||
| 14 | |||
| 15 | var sock_arg: ?[]const u8 = null; | ||
| 16 | var i: usize = 1; | ||
| 17 | while (i < args.len) : (i += 1) { | ||
| 18 | if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) { | ||
| 19 | i += 1; | ||
| 20 | sock_arg = args[i]; | ||
| 21 | } else { | ||
| 22 | std.debug.print("{s}", .{usage}); | ||
| 23 | return 2; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | const sock_path = if (sock_arg) |s| | ||
| 28 | try alloc.dupe(u8, s) | ||
| 29 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | ||
| 30 | try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}) | ||
| 31 | else | ||
| 32 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | ||
| 33 | defer alloc.free(sock_path); | ||
| 34 | |||
| 35 | return client.attach(alloc, sock_path); | ||
| 36 | } | ||