a518671e
feat: mux --via arbitrary-command transport; muxd proxy byte pump
a73x 2026-08-08 14:08
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -58,6 +58,15 @@ pub fn build(b: *std.Build) void { | |||
| 58 | }); | 58 | }); |
| 59 | mux_mod.addImport("client", client_mod); | 59 | mux_mod.addImport("client", client_mod); |
| 60 | 60 | ||
| 61 | // No imports, deliberately: the proxy is a byte pump that knows nothing | ||
| 62 | // about the protocol it carries. | ||
| 63 | const proxy_mod = b.createModule(.{ | ||
| 64 | .root_source_file = b.path("src/proxy.zig"), | ||
| 65 | .target = target, | ||
| 66 | .optimize = optimize, | ||
| 67 | .link_libc = true, | ||
| 68 | }); | ||
| 69 | |||
| 61 | const exe_mod = b.createModule(.{ | 70 | const exe_mod = b.createModule(.{ |
| 62 | .root_source_file = b.path("src/main.zig"), | 71 | .root_source_file = b.path("src/main.zig"), |
| 63 | .target = target, | 72 | .target = target, |
| @@ -66,6 +75,7 @@ pub fn build(b: *std.Build) void { | |||
| 66 | }); | 75 | }); |
| 67 | exe_mod.addImport("server", server_mod); | 76 | exe_mod.addImport("server", server_mod); |
| 68 | exe_mod.addImport("protocol", protocol_mod); | 77 | exe_mod.addImport("protocol", protocol_mod); |
| 78 | exe_mod.addImport("proxy", proxy_mod); | ||
| 69 | 79 | ||
| 70 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); | 80 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); |
| 71 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe | 81 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe |
| @@ -80,7 +90,7 @@ pub fn build(b: *std.Build) void { | |||
| 80 | b.installArtifact(mux_exe); | 90 | b.installArtifact(mux_exe); |
| 81 | 91 | ||
| 82 | const test_step = b.step("test", "Run unit tests"); | 92 | const test_step = b.step("test", "Run unit tests"); |
| 83 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod }) |mod| { | 93 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod }) |mod| { |
| 84 | const t = b.addTest(.{ .root_module = mod }); | 94 | const t = b.addTest(.{ .root_module = mod }); |
| 85 | t.use_llvm = true; | 95 | t.use_llvm = true; |
| 86 | t.use_lld = true; | 96 | t.use_lld = true; |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,16 +15,57 @@ fn onWinch(_: c_int) callconv(.c) void { | |||
| 15 | winch_flag.store(true, .release); | 15 | winch_flag.store(true, .release); |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | 18 | /// The client's transport: a read fd and a write fd. For a unix socket they |
| 19 | const stream = std.net.connectUnixSocket(sock_path) catch { | 19 | /// are one and the same; under `--via` they are the child command's stdout |
| 20 | std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path}); | 20 | /// and stdin. Nothing below the transport setup knows which it is — that |
| 21 | /// blindness is the point of the M6 spike. | ||
| 22 | const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t }; | ||
| 23 | |||
| 24 | /// Attach over a local socket (`sock_path`) or over an arbitrary command's | ||
| 25 | /// stdio (`via`, typically `ssh host muxd proxy ...`). Exactly one is | ||
| 26 | /// non-null; mux_main.zig enforces that. | ||
| 27 | pub fn attach(alloc: std.mem.Allocator, sock_path: ?[]const u8, via: ?[]const u8) !u8 { | ||
| 28 | if (via) |cmd| { | ||
| 29 | var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc); | ||
| 30 | child.stdin_behavior = .Pipe; | ||
| 31 | child.stdout_behavior = .Pipe; | ||
| 32 | // Inherited, not piped: ssh's diagnostics (auth failure, unknown host, | ||
| 33 | // connection refused) are the user's only clue when the transport | ||
| 34 | // never comes up, and we would otherwise swallow them. | ||
| 35 | child.stderr_behavior = .Inherit; | ||
| 36 | child.spawn() catch { | ||
| 37 | std.debug.print("mux: cannot start --via command: {s}\n", .{cmd}); | ||
| 38 | return 1; | ||
| 39 | }; | ||
| 40 | defer { | ||
| 41 | // Close stdin first so the command sees EOF and can wind down its | ||
| 42 | // remote end cleanly; then TERM it. kill() waitpid()s internally, | ||
| 43 | // so this also reaps — no zombie is left behind. | ||
| 44 | if (child.stdin) |*in| { | ||
| 45 | in.close(); | ||
| 46 | child.stdin = null; | ||
| 47 | } | ||
| 48 | _ = child.kill() catch {}; | ||
| 49 | } | ||
| 50 | return session(alloc, .{ .r = child.stdout.?.handle, .w = child.stdin.?.handle }); | ||
| 51 | } | ||
| 52 | |||
| 53 | const stream = std.net.connectUnixSocket(sock_path.?) catch { | ||
| 54 | std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path.?}); | ||
| 21 | return 1; | 55 | return 1; |
| 22 | }; | 56 | }; |
| 23 | defer stream.close(); | 57 | defer stream.close(); |
| 24 | const sock = stream.handle; | 58 | return session(alloc, .{ .r = stream.handle, .w = stream.handle }); |
| 59 | } | ||
| 25 | 60 | ||
| 61 | fn session(alloc: std.mem.Allocator, conn: Conn) !u8 { | ||
| 26 | // A daemon that dies mid-write must surface as an error return from | 62 | // A daemon that dies mid-write must surface as an error return from |
| 27 | // write(), not deliver a fatal SIGPIPE. | 63 | // write(), not a fatal SIGPIPE. Zig's start.zig already installs a noop |
| 64 | // SIGPIPE handler, so this is defence in depth rather than the thing that | ||
| 65 | // makes EPIPE reachable. The one difference that matters: SIG_IGN survives | ||
| 66 | // exec while a handler does not, so this must stay *after* the `--via` | ||
| 67 | // child is spawned (it is — attach() spawns, then calls us), or ssh and | ||
| 68 | // the remote proxy would inherit an ignored SIGPIPE they never asked for. | ||
| 28 | var ign: std.posix.Sigaction = .{ | 69 | var ign: std.posix.Sigaction = .{ |
| 29 | .handler = .{ .handler = std.posix.SIG.IGN }, | 70 | .handler = .{ .handler = std.posix.SIG.IGN }, |
| 30 | .mask = std.posix.sigemptyset(), | 71 | .mask = std.posix.sigemptyset(), |
| @@ -46,8 +87,10 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 46 | var exit_msg: ?[]const u8 = null; | 87 | var exit_msg: ?[]const u8 = null; |
| 47 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); | 88 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); |
| 48 | 89 | ||
| 49 | // Raw mode + alternate screen when we own a terminal. | 90 | // Raw mode when we own a terminal. The alternate screen is NOT entered |
| 91 | // here — see the first-frame gate in the loop below. | ||
| 50 | var orig_termios: ?std.posix.termios = null; | 92 | var orig_termios: ?std.posix.termios = null; |
| 93 | var alt_screen = false; | ||
| 51 | if (is_tty) { | 94 | if (is_tty) { |
| 52 | const orig = try std.posix.tcgetattr(stdin_fd); | 95 | const orig = try std.posix.tcgetattr(stdin_fd); |
| 53 | orig_termios = orig; | 96 | orig_termios = orig; |
| @@ -58,9 +101,6 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 58 | raw.iflag.IXON = false; | 101 | raw.iflag.IXON = false; |
| 59 | raw.iflag.ICRNL = false; | 102 | raw.iflag.ICRNL = false; |
| 60 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | 103 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); |
| 61 | // Autowrap off: an oversized grid row must clip at the right edge | ||
| 62 | // rather than wrap onto the next line and shift the whole paint. | ||
| 63 | try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l"); | ||
| 64 | 104 | ||
| 65 | var sa: std.posix.Sigaction = .{ | 105 | var sa: std.posix.Sigaction = .{ |
| 66 | .handler = .{ .handler = onWinch }, | 106 | .handler = .{ .handler = onWinch }, |
| @@ -69,14 +109,21 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 69 | }; | 109 | }; |
| 70 | std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); | 110 | std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); |
| 71 | } | 111 | } |
| 72 | defer if (orig_termios) |t| { | 112 | defer { |
| 73 | proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; | 113 | // Only undo what was actually done: leaving the alternate screen we |
| 74 | std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | 114 | // never entered would wipe the user's own scrollback. |
| 75 | }; | 115 | if (alt_screen) proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; |
| 116 | if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 117 | } | ||
| 76 | 118 | ||
| 77 | // A fresh client process holds no state, so it asks for a full snapshot: | 119 | // A fresh client process holds no state, so it asks for a full snapshot: |
| 78 | // no seq, and no epoch to interpret one in. | 120 | // no seq, and no epoch to interpret one in. A transport that died between |
| 79 | try proto.writeFrame(sock, .attach, &proto.encodeAttach(size.cols, size.rows, 0, 0)); | 121 | // spawn and here (ssh refused, host unreachable) makes this a broken pipe; |
| 122 | // that is a message, not a crash. | ||
| 123 | proto.writeFrame(conn.w, .attach, &proto.encodeAttach(size.cols, size.rows, 0, 0)) catch { | ||
| 124 | exit_msg = "mux: connection to muxd lost"; | ||
| 125 | return 1; | ||
| 126 | }; | ||
| 80 | 127 | ||
| 81 | var stdin_open = true; | 128 | var stdin_open = true; |
| 82 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. | 129 | // Scroll mode: 0 = live; N = viewing the page N screenfuls above live. |
| @@ -105,23 +152,47 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 105 | // follows the daemon, which answers the resize frame | 152 | // follows the daemon, which answers the resize frame |
| 106 | // with a snapshot carrying the new grid size. | 153 | // with a snapshot carrying the new grid size. |
| 107 | size = new_size; | 154 | size = new_size; |
| 108 | try proto.writeFrame(sock, .resize, &proto.encodeSize(size.cols, size.rows)); | 155 | // Same rule as every other transport write: a dead |
| 156 | // transport is reported, never thrown. | ||
| 157 | proto.writeFrame(conn.w, .resize, &proto.encodeSize(size.cols, size.rows)) catch { | ||
| 158 | exit_msg = "mux: connection to muxd lost"; | ||
| 159 | return 1; | ||
| 160 | }; | ||
| 109 | } | 161 | } |
| 110 | } | 162 | } |
| 111 | } | 163 | } |
| 112 | 164 | ||
| 113 | var fds = [_]std.posix.pollfd{ | 165 | var fds = [_]std.posix.pollfd{ |
| 114 | .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 }, | 166 | .{ .fd = conn.r, .events = std.posix.POLL.IN, .revents = 0 }, |
| 115 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | 167 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, |
| 116 | }; | 168 | }; |
| 117 | _ = try std.posix.poll(&fds, 100); | 169 | _ = try std.posix.poll(&fds, 100); |
| 118 | 170 | ||
| 119 | if (fds[0].revents != 0) { | 171 | if (fds[0].revents != 0) { |
| 120 | const frame = (try proto.readFrame(alloc, sock)) orelse { | 172 | // A clean EOF and a frame torn in half are the same event to us: |
| 173 | // the transport is gone. Over ssh the tear is the ordinary case — | ||
| 174 | // the channel drops mid-frame — so it has to read as a message | ||
| 175 | // rather than a stack trace. OutOfMemory is not a transport event | ||
| 176 | // and stays loud. | ||
| 177 | const frame = (proto.readFrame(alloc, conn.r) catch |err| switch (err) { | ||
| 178 | error.OutOfMemory => return err, | ||
| 179 | else => null, | ||
| 180 | }) orelse { | ||
| 121 | exit_msg = "mux: connection to muxd lost"; | 181 | exit_msg = "mux: connection to muxd lost"; |
| 122 | return 1; | 182 | return 1; |
| 123 | }; | 183 | }; |
| 124 | defer frame.deinit(alloc); | 184 | defer frame.deinit(alloc); |
| 185 | // The alternate screen waits for proof that the transport works. | ||
| 186 | // Entering at setup would erase whatever the `--via` command wrote | ||
| 187 | // to its inherited stderr (ssh reports auth and connection failures | ||
| 188 | // hundreds of ms after spawn), and would blank the screen for the | ||
| 189 | // whole of a hang like `mux --via "sleep 30"`. Autowrap goes off | ||
| 190 | // with it: an oversized grid row must clip at the right edge rather | ||
| 191 | // than wrap and shift the whole paint. | ||
| 192 | if (is_tty and !alt_screen) { | ||
| 193 | try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l"); | ||
| 194 | alt_screen = true; | ||
| 195 | } | ||
| 125 | switch (frame.type) { | 196 | switch (frame.type) { |
| 126 | .snapshot => { | 197 | .snapshot => { |
| 127 | const prefix = proto.readSnapshotPrefix(frame.payload) catch continue; | 198 | const prefix = proto.readSnapshotPrefix(frame.payload) catch continue; |
| @@ -149,7 +220,7 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 149 | // silently skipping it and desyncing for good. By | 220 | // silently skipping it and desyncing for good. By |
| 150 | // latest-wins this re-attach also re-asserts our size | 221 | // latest-wins this re-attach also re-asserts our size |
| 151 | // onto the shared session — accepted. | 222 | // onto the shared session — accepted. |
| 152 | proto.writeFrame(sock, .attach, &proto.encodeAttach(size.cols, size.rows, 0, 0)) catch {}; | 223 | proto.writeFrame(conn.w, .attach, &proto.encodeAttach(size.cols, size.rows, 0, 0)) catch {}; |
| 153 | continue; | 224 | continue; |
| 154 | }; | 225 | }; |
| 155 | defer alloc.free(composed.bytes); | 226 | defer alloc.free(composed.bytes); |
| @@ -188,7 +259,7 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 188 | } else { | 259 | } else { |
| 189 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { | 260 | if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) { |
| 190 | // Ctrl-\: detach and leave the session running. | 261 | // Ctrl-\: detach and leave the session running. |
| 191 | proto.writeFrame(sock, .detach, "") catch {}; | 262 | proto.writeFrame(conn.w, .detach, "") catch {}; |
| 192 | exit_msg = "mux: detached (session still running; run mux to reattach)"; | 263 | exit_msg = "mux: detached (session still running; run mux to reattach)"; |
| 193 | return 0; | 264 | return 0; |
| 194 | } | 265 | } |
| @@ -198,21 +269,30 @@ pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 198 | if (history_rows > 0) { | 269 | if (history_rows > 0) { |
| 199 | const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; | 270 | const max_pages: u32 = (history_rows + size.rows - 1) / size.rows; |
| 200 | if (scroll_pages < max_pages) scroll_pages += 1; | 271 | if (scroll_pages < max_pages) scroll_pages += 1; |
| 201 | try requestScrollPage(sock, scroll_pages, history_rows, size); | 272 | requestScrollPage(conn.w, scroll_pages, history_rows, size) catch { |
| 273 | exit_msg = "mux: connection to muxd lost"; | ||
| 274 | return 1; | ||
| 275 | }; | ||
| 202 | } | 276 | } |
| 203 | } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) { | 277 | } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) { |
| 204 | if (scroll_pages > 0) scroll_pages -= 1; | 278 | if (scroll_pages > 0) scroll_pages -= 1; |
| 205 | if (scroll_pages == 0) { | 279 | if (scroll_pages == 0) { |
| 206 | try renderClipped(alloc, replica, size, stdout_fd); | 280 | try renderClipped(alloc, replica, size, stdout_fd); |
| 207 | } else { | 281 | } else { |
| 208 | try requestScrollPage(sock, scroll_pages, history_rows, size); | 282 | requestScrollPage(conn.w, scroll_pages, history_rows, size) catch { |
| 283 | exit_msg = "mux: connection to muxd lost"; | ||
| 284 | return 1; | ||
| 285 | }; | ||
| 209 | } | 286 | } |
| 210 | } else if (scroll_pages > 0) { | 287 | } else if (scroll_pages > 0) { |
| 211 | // Any other key exits scroll mode (swallowed, not forwarded). | 288 | // Any other key exits scroll mode (swallowed, not forwarded). |
| 212 | scroll_pages = 0; | 289 | scroll_pages = 0; |
| 213 | try renderClipped(alloc, replica, size, stdout_fd); | 290 | try renderClipped(alloc, replica, size, stdout_fd); |
| 214 | } else { | 291 | } else { |
| 215 | try proto.writeFrame(sock, .input, buf[0..n]); | 292 | proto.writeFrame(conn.w, .input, buf[0..n]) catch { |
| 293 | exit_msg = "mux: connection to muxd lost"; | ||
| 294 | return 1; | ||
| 295 | }; | ||
| 216 | } | 296 | } |
| 217 | } | 297 | } |
| 218 | } | 298 | } |
| @@ -287,7 +367,7 @@ fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.S | |||
| 287 | } | 367 | } |
| 288 | 368 | ||
| 289 | fn requestScrollPage( | 369 | fn requestScrollPage( |
| 290 | sock: std.posix.fd_t, | 370 | w: std.posix.fd_t, |
| 291 | pages_up: u32, | 371 | pages_up: u32, |
| 292 | history_rows: u32, | 372 | history_rows: u32, |
| 293 | size: proto.Size, | 373 | size: proto.Size, |
| @@ -296,7 +376,7 @@ fn requestScrollPage( | |||
| 296 | // top (screen-space row index history_rows). | 376 | // top (screen-space row index history_rows). |
| 297 | const rows: u32 = size.rows; | 377 | const rows: u32 = size.rows; |
| 298 | const start = history_rows -| (pages_up * rows); | 378 | const start = history_rows -| (pages_up * rows); |
| 299 | try proto.writeFrame(sock, .fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); | 379 | try proto.writeFrame(w, .fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); |
| 300 | } | 380 | } |
| 301 | 381 | ||
| 302 | /// Paint a fetched history page: clear, rows, and an inverse [scroll] | 382 | /// Paint a fetched history page: clear, rows, and an inverse [scroll] |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,14 +1,17 @@ | |||
| 1 | //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the | 1 | //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the |
| 2 | //! authoritative grid over the protocol (debug aid, also used by e2e). | 2 | //! authoritative grid over the protocol (debug aid, also used by e2e); |
| 3 | //! `proxy` exposes the session socket over stdio for `mux --via`. | ||
| 3 | const std = @import("std"); | 4 | const std = @import("std"); |
| 4 | const Server = @import("server").Server; | 5 | const Server = @import("server").Server; |
| 5 | const proto = @import("protocol"); | 6 | const proto = @import("protocol"); |
| 7 | const proxy = @import("proxy"); | ||
| 6 | 8 | ||
| 7 | const usage = | 9 | const usage = |
| 8 | \\usage: | 10 | \\usage: |
| 9 | \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] | 11 | \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] |
| 10 | \\ muxd dump [--vt] [--sock PATH] | 12 | \\ muxd dump [--vt] [--sock PATH] |
| 11 | \\ muxd stats [--sock PATH] | 13 | \\ muxd stats [--sock PATH] |
| 14 | \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) | ||
| 12 | \\ | 15 | \\ |
| 13 | ; | 16 | ; |
| 14 | 17 | ||
| @@ -78,6 +81,7 @@ pub fn main() !u8 { | |||
| 78 | } | 81 | } |
| 79 | if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); | 82 | if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); |
| 80 | if (std.mem.eql(u8, args[1], "stats")) return stats(alloc, sock_path); | 83 | if (std.mem.eql(u8, args[1], "stats")) return stats(alloc, sock_path); |
| 84 | if (std.mem.eql(u8, args[1], "proxy")) return proxy.run(sock_path); | ||
| 81 | std.debug.print("{s}", .{usage}); | 85 | std.debug.print("{s}", .{usage}); |
| 82 | return 2; | 86 | return 2; |
| 83 | } | 87 | } |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,10 @@ | |||
| 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd. | 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the local muxd; |
| 2 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that | ||
| 3 | //! exposes a session socket as a byte pipe, e.g. `ssh host muxd proxy`). | ||
| 2 | const std = @import("std"); | 4 | const std = @import("std"); |
| 3 | const client = @import("client"); | 5 | const client = @import("client"); |
| 4 | 6 | ||
| 5 | const usage = "usage: mux [--sock PATH]\n"; | 7 | const usage = "usage: mux [--sock PATH | --via CMD]\n"; |
| 6 | 8 | ||
| 7 | pub fn main() !u8 { | 9 | pub fn main() !u8 { |
| 8 | var gpa: std.heap.DebugAllocator(.{}) = .init; | 10 | var gpa: std.heap.DebugAllocator(.{}) = .init; |
| @@ -13,17 +15,29 @@ pub fn main() !u8 { | |||
| 13 | defer std.process.argsFree(alloc, args); | 15 | defer std.process.argsFree(alloc, args); |
| 14 | 16 | ||
| 15 | var sock_arg: ?[]const u8 = null; | 17 | var sock_arg: ?[]const u8 = null; |
| 18 | var via_arg: ?[]const u8 = null; | ||
| 16 | var i: usize = 1; | 19 | var i: usize = 1; |
| 17 | while (i < args.len) : (i += 1) { | 20 | while (i < args.len) : (i += 1) { |
| 18 | if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) { | 21 | if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) { |
| 19 | i += 1; | 22 | i += 1; |
| 20 | sock_arg = args[i]; | 23 | sock_arg = args[i]; |
| 24 | } else if (std.mem.eql(u8, args[i], "--via") and i + 1 < args.len) { | ||
| 25 | i += 1; | ||
| 26 | via_arg = args[i]; | ||
| 21 | } else { | 27 | } else { |
| 22 | std.debug.print("{s}", .{usage}); | 28 | std.debug.print("{s}", .{usage}); |
| 23 | return 2; | 29 | return 2; |
| 24 | } | 30 | } |
| 25 | } | 31 | } |
| 26 | 32 | ||
| 33 | // Two transports, one session: naming both is a request we cannot honour | ||
| 34 | // rather than one to reconcile. | ||
| 35 | if (sock_arg != null and via_arg != null) { | ||
| 36 | std.debug.print("mux: --sock and --via are mutually exclusive\n{s}", .{usage}); | ||
| 37 | return 2; | ||
| 38 | } | ||
| 39 | if (via_arg) |cmd| return client.attach(alloc, null, cmd); | ||
| 40 | |||
| 27 | const sock_path = if (sock_arg) |s| | 41 | const sock_path = if (sock_arg) |s| |
| 28 | try alloc.dupe(u8, s) | 42 | try alloc.dupe(u8, s) |
| 29 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | 43 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| |
| @@ -32,5 +46,5 @@ pub fn main() !u8 { | |||
| 32 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); | 46 | try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); |
| 33 | defer alloc.free(sock_path); | 47 | defer alloc.free(sock_path); |
| 34 | 48 | ||
| 35 | return client.attach(alloc, sock_path); | 49 | return client.attach(alloc, sock_path, null); |
| 36 | } | 50 | } |
src/proxy.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,363 @@ | |||
| 1 | //! `muxd proxy`: a bidirectional byte pump between stdio and the local | ||
| 2 | //! daemon socket. Deliberately frame-agnostic — it contains no protocol | ||
| 3 | //! knowledge at all. That is the M6 transport thesis: if an opaque byte | ||
| 4 | //! pipe suffices to carry the protocol over SSH, transport is a swap, | ||
| 5 | //! not a redesign. Keep this file's import list empty of `protocol`. | ||
| 6 | const std = @import("std"); | ||
| 7 | |||
| 8 | /// `muxd proxy` proper: pump between this process's stdio and `sock_path`. | ||
| 9 | pub fn run(sock_path: []const u8) !u8 { | ||
| 10 | return pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path); | ||
| 11 | } | ||
| 12 | |||
| 13 | /// Copy bytes in both directions until either side hangs up. `in_fd`/`out_fd` | ||
| 14 | /// are the transport (stdio under `run`, pipes under test); the socket is the | ||
| 15 | /// local daemon. Exits 0 when either side hangs up cleanly, 1 if the daemon | ||
| 16 | /// socket cannot be reached or a read or write fails. | ||
| 17 | pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8) !u8 { | ||
| 18 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 19 | std.debug.print("muxd proxy: cannot connect to {s}\n", .{sock_path}); | ||
| 20 | return 1; | ||
| 21 | }; | ||
| 22 | defer stream.close(); | ||
| 23 | const sock = stream.handle; | ||
| 24 | |||
| 25 | // Defence in depth, not a fix: Zig's start.zig already installs a noop | ||
| 26 | // SIGPIPE handler, so a hangup surfaces as EPIPE from write() rather than | ||
| 27 | // killing us, and the error returns below are reachable without this. What | ||
| 28 | // this pins is that the reachability belongs to this file instead of to a | ||
| 29 | // std default (`std.options.keep_sigpipe`) another module could flip. The | ||
| 30 | // one real difference between the two: SIG_IGN survives exec, a handler | ||
| 31 | // does not — which is why client.zig installs its ignore only after | ||
| 32 | // spawning the transport child, and why the order matters there and not | ||
| 33 | // here (the proxy spawns nothing). | ||
| 34 | var ign: std.posix.Sigaction = .{ | ||
| 35 | .handler = .{ .handler = std.posix.SIG.IGN }, | ||
| 36 | .mask = std.posix.sigemptyset(), | ||
| 37 | .flags = 0, | ||
| 38 | }; | ||
| 39 | std.posix.sigaction(std.posix.SIG.PIPE, &ign, null); | ||
| 40 | |||
| 41 | var buf: [64 * 1024]u8 = undefined; | ||
| 42 | while (true) { | ||
| 43 | var fds = [_]std.posix.pollfd{ | ||
| 44 | .{ .fd = in_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 45 | .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 46 | }; | ||
| 47 | _ = try std.posix.poll(&fds, -1); | ||
| 48 | |||
| 49 | if (fds[0].revents != 0) { | ||
| 50 | const n = std.posix.read(in_fd, &buf) catch return 1; | ||
| 51 | if (n == 0) return 0; // client hung up: nothing left to carry | ||
| 52 | writeAll(sock, buf[0..n]) catch return 1; | ||
| 53 | } | ||
| 54 | if (fds[1].revents != 0) { | ||
| 55 | const n = std.posix.read(sock, &buf) catch return 1; | ||
| 56 | // The daemon hung up: exit, never reconnect. A delta stream cannot | ||
| 57 | // outlive the daemon instance that opened it (see the epoch note at | ||
| 58 | // src/protocol.zig), so a reconnecting proxy would silently rebind a | ||
| 59 | // client to a different session — or to a restarted one — instead of | ||
| 60 | // letting it learn its session died. Honest failure beats a pipe | ||
| 61 | // that heals into a lie. | ||
| 62 | if (n == 0) return 0; | ||
| 63 | writeAll(out_fd, buf[0..n]) catch return 1; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | fn writeAll(fd: std.posix.fd_t, data: []const u8) !void { | ||
| 69 | var i: usize = 0; | ||
| 70 | while (i < data.len) i += try std.posix.write(fd, data[i..]); | ||
| 71 | } | ||
| 72 | |||
| 73 | /// Runs `pump` off the test's main thread so a pump that fails to exit fails | ||
| 74 | /// an assertion instead of wedging the suite. That matters specifically here: | ||
| 75 | /// break the EOF-exit invariant and the loop spins on a readable-but-empty | ||
| 76 | /// socket, which nothing outside the pump can interrupt. | ||
| 77 | const PumpRunner = struct { | ||
| 78 | in_fd: std.posix.fd_t, | ||
| 79 | out_fd: std.posix.fd_t, | ||
| 80 | sock_path: []const u8, | ||
| 81 | rc: u8 = 0xff, | ||
| 82 | failed: bool = false, | ||
| 83 | done: std.atomic.Value(bool) = .init(false), | ||
| 84 | |||
| 85 | fn run(self: *PumpRunner) void { | ||
| 86 | self.rc = pump(self.in_fd, self.out_fd, self.sock_path) catch blk: { | ||
| 87 | self.failed = true; | ||
| 88 | break :blk 0xff; | ||
| 89 | }; | ||
| 90 | self.done.store(true, .release); | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Wait for the pump to return. On timeout the caller must NOT join: a | ||
| 94 | /// spinning pump never returns, so the thread is abandoned to process exit | ||
| 95 | /// and the test fails rather than hanging. | ||
| 96 | fn wait(self: *PumpRunner, ms: u32) !void { | ||
| 97 | var waited: u32 = 0; | ||
| 98 | while (waited < ms) : (waited += 20) { | ||
| 99 | if (self.done.load(.acquire)) return; | ||
| 100 | std.Thread.sleep(20 * std.time.ns_per_ms); | ||
| 101 | } | ||
| 102 | return error.PumpDidNotExit; | ||
| 103 | } | ||
| 104 | }; | ||
| 105 | |||
| 106 | /// A one-shot peer on a unix socket: accepts, reads `want` bytes, then sends | ||
| 107 | /// `reply` and closes. Closing is what ends the pump under test. | ||
| 108 | const EchoPeer = struct { | ||
| 109 | listener: *std.net.Server, | ||
| 110 | alloc: std.mem.Allocator, | ||
| 111 | want: usize, | ||
| 112 | reply: []const u8, | ||
| 113 | got: std.ArrayList(u8) = .empty, | ||
| 114 | |||
| 115 | fn run(self: *EchoPeer) void { | ||
| 116 | const conn = self.listener.accept() catch return; | ||
| 117 | defer conn.stream.close(); | ||
| 118 | var buf: [4096]u8 = undefined; | ||
| 119 | // Idle deadline rather than a blocking read: a pump that delivers only | ||
| 120 | // some of the bytes must fail the byte-for-byte assertion below, not | ||
| 121 | // wedge the test suite waiting for the rest. | ||
| 122 | var idle: i32 = 0; | ||
| 123 | while (self.got.items.len < self.want and idle < 3000) { | ||
| 124 | var fds = [_]std.posix.pollfd{ | ||
| 125 | .{ .fd = conn.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 126 | }; | ||
| 127 | const ready = std.posix.poll(&fds, 100) catch return; | ||
| 128 | if (ready == 0) { | ||
| 129 | idle += 100; | ||
| 130 | continue; | ||
| 131 | } | ||
| 132 | const n = std.posix.read(conn.stream.handle, &buf) catch return; | ||
| 133 | if (n == 0) break; | ||
| 134 | self.got.appendSlice(self.alloc, buf[0..n]) catch return; | ||
| 135 | idle = 0; | ||
| 136 | } | ||
| 137 | writeAll(conn.stream.handle, self.reply) catch return; | ||
| 138 | } | ||
| 139 | }; | ||
| 140 | |||
| 141 | /// Feeds a pipe from its own thread: more than a pipe buffer's worth of input | ||
| 142 | /// cannot be queued up front. | ||
| 143 | const PipeWriter = struct { | ||
| 144 | fd: std.posix.fd_t, | ||
| 145 | data: []const u8, | ||
| 146 | fn run(self: *PipeWriter) void { | ||
| 147 | writeAll(self.fd, self.data) catch return; | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | |||
| 151 | /// Drains a pipe concurrently with the pump, stopping once it has been idle | ||
| 152 | /// for a while. Idle-based rather than EOF-based so the test never has to | ||
| 153 | /// close the pump's output fd at a particular moment. | ||
| 154 | const PipeDrainer = struct { | ||
| 155 | fd: std.posix.fd_t, | ||
| 156 | alloc: std.mem.Allocator, | ||
| 157 | out: std.ArrayList(u8) = .empty, | ||
| 158 | fn run(self: *PipeDrainer, idle_limit_ms: i32) void { | ||
| 159 | var idle: i32 = 0; | ||
| 160 | var buf: [8192]u8 = undefined; | ||
| 161 | while (idle < idle_limit_ms) { | ||
| 162 | var fds = [_]std.posix.pollfd{ | ||
| 163 | .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 164 | }; | ||
| 165 | const ready = std.posix.poll(&fds, 100) catch return; | ||
| 166 | if (ready == 0) { | ||
| 167 | idle += 100; | ||
| 168 | continue; | ||
| 169 | } | ||
| 170 | const n = std.posix.read(self.fd, &buf) catch return; | ||
| 171 | if (n == 0) return; | ||
| 172 | self.out.appendSlice(self.alloc, buf[0..n]) catch return; | ||
| 173 | idle = 0; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | }; | ||
| 177 | |||
| 178 | /// Shrink a socket's buffers so a transfer larger than them cannot be swallowed | ||
| 179 | /// whole by the kernel. Set on the listener, it is inherited by the accepted | ||
| 180 | /// connection. Linux doubles and clamps the request, so this is a floor | ||
| 181 | /// request, not a promise — the tests depend only on it being small. | ||
| 182 | fn shrinkBufs(fd: std.posix.fd_t) void { | ||
| 183 | const v: c_int = 1024; | ||
| 184 | std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&v)) catch {}; | ||
| 185 | std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.SNDBUF, std.mem.asBytes(&v)) catch {}; | ||
| 186 | } | ||
| 187 | |||
| 188 | fn readExactly(fd: std.posix.fd_t, buf: []u8) !void { | ||
| 189 | var i: usize = 0; | ||
| 190 | while (i < buf.len) { | ||
| 191 | const n = try std.posix.read(fd, buf[i..]); | ||
| 192 | if (n == 0) return error.UnexpectedEof; | ||
| 193 | i += n; | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | test "pump carries bytes both ways verbatim" { | ||
| 198 | const alloc = std.testing.allocator; | ||
| 199 | |||
| 200 | var tmp = std.testing.tmpDir(.{}); | ||
| 201 | defer tmp.cleanup(); | ||
| 202 | var path_buf: [256]u8 = undefined; | ||
| 203 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 204 | var sock_buf: [280]u8 = undefined; | ||
| 205 | const sock_path = try std.fmt.bufPrint(&sock_buf, "{s}/proxy.sock", .{dir_path}); | ||
| 206 | |||
| 207 | const addr = try std.net.Address.initUnix(sock_path); | ||
| 208 | var listener = try addr.listen(.{}); | ||
| 209 | defer listener.deinit(); | ||
| 210 | |||
| 211 | // Frame-shaped payloads with embedded zeros and 0xFF: the pump must be | ||
| 212 | // byte-transparent, not text-safe. | ||
| 213 | const to_daemon = [_]u8{ 0x01, 0x08, 0x00, 0x00, 0x00, 0xff, 0x00, 0x1b, 0x5c, 0x00, 0x7f, 0xfe, 0x0a }; | ||
| 214 | const to_client = [_]u8{ 0x80, 0x06, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0xff, 0x1c, 0x00 }; | ||
| 215 | |||
| 216 | const in_pipe = try std.posix.pipe(); | ||
| 217 | defer std.posix.close(in_pipe[0]); | ||
| 218 | defer std.posix.close(in_pipe[1]); | ||
| 219 | const out_pipe = try std.posix.pipe(); | ||
| 220 | defer std.posix.close(out_pipe[0]); | ||
| 221 | defer std.posix.close(out_pipe[1]); | ||
| 222 | |||
| 223 | // Queued before the pump starts; the pipe buffer holds it, so nothing here | ||
| 224 | // depends on thread scheduling. | ||
| 225 | try writeAll(in_pipe[1], &to_daemon); | ||
| 226 | |||
| 227 | var peer = EchoPeer{ | ||
| 228 | .listener = &listener, | ||
| 229 | .alloc = alloc, | ||
| 230 | .want = to_daemon.len, | ||
| 231 | .reply = &to_client, | ||
| 232 | }; | ||
| 233 | defer peer.got.deinit(alloc); | ||
| 234 | const peer_th = try std.Thread.spawn(.{}, EchoPeer.run, .{&peer}); | ||
| 235 | |||
| 236 | var runner = PumpRunner{ .in_fd = in_pipe[0], .out_fd = out_pipe[1], .sock_path = sock_path }; | ||
| 237 | const pump_th = try std.Thread.spawn(.{}, PumpRunner.run, .{&runner}); | ||
| 238 | // Returns when the peer closes — the daemon-hangup path. | ||
| 239 | try runner.wait(5000); | ||
| 240 | pump_th.join(); | ||
| 241 | peer_th.join(); | ||
| 242 | |||
| 243 | try std.testing.expect(!runner.failed); | ||
| 244 | try std.testing.expectEqual(@as(u8, 0), runner.rc); | ||
| 245 | try std.testing.expectEqualSlices(u8, &to_daemon, peer.got.items); | ||
| 246 | |||
| 247 | // Read a known length rather than to EOF: the write end is still open. | ||
| 248 | var out: [to_client.len]u8 = undefined; | ||
| 249 | try readExactly(out_pipe[0], &out); | ||
| 250 | try std.testing.expectEqualSlices(u8, &to_client, &out); | ||
| 251 | } | ||
| 252 | |||
| 253 | test "pump carries a large transfer verbatim under backpressure" { | ||
| 254 | const alloc = std.testing.allocator; | ||
| 255 | |||
| 256 | var tmp = std.testing.tmpDir(.{}); | ||
| 257 | defer tmp.cleanup(); | ||
| 258 | var path_buf: [256]u8 = undefined; | ||
| 259 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 260 | var sock_buf: [280]u8 = undefined; | ||
| 261 | const sock_path = try std.fmt.bufPrint(&sock_buf, "{s}/big.sock", .{dir_path}); | ||
| 262 | |||
| 263 | const addr = try std.net.Address.initUnix(sock_path); | ||
| 264 | var listener = try addr.listen(.{}); | ||
| 265 | defer listener.deinit(); | ||
| 266 | // Backpressure: far more data than either buffer can hold, so the copy | ||
| 267 | // loops in both directions instead of completing in one syscall. | ||
| 268 | shrinkBufs(listener.stream.handle); | ||
| 269 | |||
| 270 | const size = 300 * 1024; | ||
| 271 | const up = try alloc.alloc(u8, size); | ||
| 272 | defer alloc.free(up); | ||
| 273 | const down = try alloc.alloc(u8, size); | ||
| 274 | defer alloc.free(down); | ||
| 275 | for (up, 0..) |*b, i| b.* = @truncate(i *% 31 +% (i >> 8)); | ||
| 276 | for (down, 0..) |*b, i| b.* = @truncate(i *% 37 +% (i >> 7) +% 11); | ||
| 277 | |||
| 278 | const in_pipe = try std.posix.pipe(); | ||
| 279 | defer std.posix.close(in_pipe[0]); | ||
| 280 | defer std.posix.close(in_pipe[1]); | ||
| 281 | const out_pipe = try std.posix.pipe(); | ||
| 282 | defer std.posix.close(out_pipe[0]); | ||
| 283 | defer std.posix.close(out_pipe[1]); | ||
| 284 | |||
| 285 | var peer = EchoPeer{ .listener = &listener, .alloc = alloc, .want = size, .reply = down }; | ||
| 286 | defer peer.got.deinit(alloc); | ||
| 287 | const peer_th = try std.Thread.spawn(.{}, EchoPeer.run, .{&peer}); | ||
| 288 | |||
| 289 | // Both ends need their own thread: 300KB exceeds a pipe buffer, so a | ||
| 290 | // single-threaded test would deadlock against its own pump. | ||
| 291 | var writer = PipeWriter{ .fd = in_pipe[1], .data = up }; | ||
| 292 | const writer_th = try std.Thread.spawn(.{}, PipeWriter.run, .{&writer}); | ||
| 293 | var drainer = PipeDrainer{ .fd = out_pipe[0], .alloc = alloc }; | ||
| 294 | defer drainer.out.deinit(alloc); | ||
| 295 | const drain_th = try std.Thread.spawn(.{}, PipeDrainer.run, .{ &drainer, 1500 }); | ||
| 296 | |||
| 297 | var runner = PumpRunner{ .in_fd = in_pipe[0], .out_fd = out_pipe[1], .sock_path = sock_path }; | ||
| 298 | const pump_th = try std.Thread.spawn(.{}, PumpRunner.run, .{&runner}); | ||
| 299 | try runner.wait(30_000); | ||
| 300 | pump_th.join(); | ||
| 301 | peer_th.join(); | ||
| 302 | writer_th.join(); | ||
| 303 | drain_th.join(); | ||
| 304 | |||
| 305 | try std.testing.expect(!runner.failed); | ||
| 306 | try std.testing.expectEqual(@as(u8, 0), runner.rc); | ||
| 307 | try std.testing.expectEqualSlices(u8, up, peer.got.items); | ||
| 308 | try std.testing.expectEqualSlices(u8, down, drainer.out.items); | ||
| 309 | } | ||
| 310 | |||
| 311 | test "pump exits when the far side of its output pipe is gone" { | ||
| 312 | const alloc = std.testing.allocator; | ||
| 313 | |||
| 314 | var tmp = std.testing.tmpDir(.{}); | ||
| 315 | defer tmp.cleanup(); | ||
| 316 | var path_buf: [256]u8 = undefined; | ||
| 317 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 318 | var sock_buf: [280]u8 = undefined; | ||
| 319 | const sock_path = try std.fmt.bufPrint(&sock_buf, "{s}/gone.sock", .{dir_path}); | ||
| 320 | |||
| 321 | const addr = try std.net.Address.initUnix(sock_path); | ||
| 322 | var listener = try addr.listen(.{}); | ||
| 323 | defer listener.deinit(); | ||
| 324 | |||
| 325 | const in_pipe = try std.posix.pipe(); | ||
| 326 | defer std.posix.close(in_pipe[0]); | ||
| 327 | defer std.posix.close(in_pipe[1]); | ||
| 328 | const out_pipe = try std.posix.pipe(); | ||
| 329 | defer std.posix.close(out_pipe[1]); | ||
| 330 | // The reader is gone before a byte is written: writing to out_fd can only | ||
| 331 | // fail. The pump must report that as a failed transfer (1), not die of | ||
| 332 | // SIGPIPE and not spin. | ||
| 333 | std.posix.close(out_pipe[0]); | ||
| 334 | |||
| 335 | var peer = EchoPeer{ .listener = &listener, .alloc = alloc, .want = 0, .reply = "x" }; | ||
| 336 | defer peer.got.deinit(alloc); | ||
| 337 | const peer_th = try std.Thread.spawn(.{}, EchoPeer.run, .{&peer}); | ||
| 338 | |||
| 339 | var runner = PumpRunner{ .in_fd = in_pipe[0], .out_fd = out_pipe[1], .sock_path = sock_path }; | ||
| 340 | const pump_th = try std.Thread.spawn(.{}, PumpRunner.run, .{&runner}); | ||
| 341 | try runner.wait(5000); | ||
| 342 | pump_th.join(); | ||
| 343 | peer_th.join(); | ||
| 344 | |||
| 345 | try std.testing.expect(!runner.failed); | ||
| 346 | try std.testing.expectEqual(@as(u8, 1), runner.rc); | ||
| 347 | } | ||
| 348 | |||
| 349 | test "pump reports a missing daemon socket instead of hanging" { | ||
| 350 | var tmp = std.testing.tmpDir(.{}); | ||
| 351 | defer tmp.cleanup(); | ||
| 352 | var path_buf: [256]u8 = undefined; | ||
| 353 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 354 | var sock_buf: [280]u8 = undefined; | ||
| 355 | const sock_path = try std.fmt.bufPrint(&sock_buf, "{s}/absent.sock", .{dir_path}); | ||
| 356 | |||
| 357 | // No fds are touched on this path, so -1 would do; use the real stdio | ||
| 358 | // constants to keep the call shape honest. | ||
| 359 | try std.testing.expectEqual( | ||
| 360 | @as(u8, 1), | ||
| 361 | try pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path), | ||
| 362 | ); | ||
| 363 | } | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -7,7 +7,7 @@ MUX="$2" | |||
| 7 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" | 7 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" |
| 8 | OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" | 8 | OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" |
| 9 | 9 | ||
| 10 | cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.b"; } | 10 | cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT" "$OUT.kill" "$OUT.re" "$OUT.a" "$OUT.b" "$OUT.via"; } |
| 11 | trap cleanup EXIT INT TERM | 11 | trap cleanup EXIT INT TERM |
| 12 | 12 | ||
| 13 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & | 13 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & |
| @@ -69,4 +69,23 @@ grep -q "m5-after-a-left" "$OUT.b" || { | |||
| 69 | kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; } | 69 | kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; } |
| 70 | rm -f "$OUT.a" "$OUT.b" | 70 | rm -f "$OUT.a" "$OUT.b" |
| 71 | 71 | ||
| 72 | # --- M6: the same protocol over an arbitrary byte pipe. `muxd proxy` is a | ||
| 73 | # frame-agnostic stdio<->socket pump; if the session works through it, the | ||
| 74 | # transport really is a swap. The string is one shell word here and is split | ||
| 75 | # by the /bin/sh -c that mux spawns, so $MUXD must contain no spaces — it is | ||
| 76 | # the build tree's artifact path, which does not. | ||
| 77 | # | ||
| 78 | # XDG_RUNTIME_DIR is pointed at nothing so the test cannot pass by environment | ||
| 79 | # luck: if --via ever silently fell back to the default socket path, that path | ||
| 80 | # would resolve to a directory that does not exist and the session would fail | ||
| 81 | # instead of quietly attaching over the local socket. `|| true` keeps set -e | ||
| 82 | # from aborting before the labelled diagnostic below runs. | ||
| 83 | { sleep 0.5; printf 'printf "m6-%%s\\n" via-pipe\n'; sleep 2; printf '\034'; } | \ | ||
| 84 | XDG_RUNTIME_DIR=/nonexistent-mux-e2e "$MUX" --via "$MUXD proxy --sock $SOCK" > "$OUT.via" || true | ||
| 85 | grep -q "m6-via-pipe" "$OUT.via" || { | ||
| 86 | echo "e2e FAIL: --via transport"; cat "$OUT.via"; exit 1; | ||
| 87 | } | ||
| 88 | kill -0 "$DPID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; } | ||
| 89 | rm -f "$OUT.via" | ||
| 90 | |||
| 72 | echo "e2e OK" | 91 | echo "e2e OK" |