8860165d
Add dial: the client side of a daemon's socket
a73x 2026-08-31 15:22
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -59,7 +59,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module. | |||
| 59 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | 59 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | |
| 60 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | 60 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | |
| 61 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | | 61 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | |
| 62 | | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link | | 62 | | `src/` | `xdg` `sockpath` `dial` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket, `term` and nothing else under it | |
| 63 | 63 | ||
| 64 | The grouping itself is a convention now, not a gate: the table wires the | 64 | The grouping itself is a convention now, not a gate: the table wires the |
| 65 | build and says who owns what, and an import edit is an ordinary diff. What | 65 | build and says who owns what, and an import edit is an ordinary diff. What |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -162,6 +162,11 @@ const mod_table = [_]ModSpec{ | |||
| 162 | // client may spell. | 162 | // client may spell. |
| 163 | .{ .name = "spawn", .path = "src/cli/spawn.zig", .link_libc = true }, | 163 | .{ .name = "spawn", .path = "src/cli/spawn.zig", .link_libc = true }, |
| 164 | // ---- single-hop over the leaves ---- | 164 | // ---- single-hop over the leaves ---- |
| 165 | // The client side of a daemon's socket: dial it, and say hello. `term` | ||
| 166 | // is the attach encoders and is all it takes, which is the point — an | ||
| 167 | // embedder reaches a daemon by linking this and the wire contract | ||
| 168 | // instead of the whole client module. | ||
| 169 | .{ .name = "dial", .path = "src/dial.zig", .imports = &.{"term"} }, | ||
| 165 | // Replays a captured client stdout stream and prints the final grid in | 170 | // Replays a captured client stdout stream and prints the final grid in |
| 166 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump | 171 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump |
| 167 | // convergence check. Imports term so both sides of the diff go through | 172 | // convergence check. Imports term so both sides of the diff go through |
| @@ -191,7 +196,7 @@ const mod_table = [_]ModSpec{ | |||
| 191 | // wire contract and nothing else — no engine and no replica, muxa having | 196 | // wire contract and nothing else — no engine and no replica, muxa having |
| 192 | // nothing to draw: a fact of muxa.zig itself, which the one-row component | 197 | // nothing to draw: a fact of muxa.zig itself, which the one-row component |
| 193 | // no longer refuses on its behalf. | 198 | // no longer refuses on its behalf. |
| 194 | .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags" }, .quic_tests = true }, | 199 | .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial" }, .quic_tests = true }, |
| 195 | .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } }, | 200 | .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } }, |
| 196 | // Dialling, and what a chord means. The client is the only thing that | 201 | // Dialling, and what a chord means. The client is the only thing that |
| 197 | // predicts — the overlay is a local display decision and never becomes | 202 | // predicts — the overlay is a local display decision and never becomes |
| @@ -206,7 +211,7 @@ const mod_table = [_]ModSpec{ | |||
| 206 | // sits here rather than in either front so the CLI wall and the browser | 211 | // sits here rather than in either front so the CLI wall and the browser |
| 207 | // hub resolve a host line the same way. Nothing here WRITES that file — | 212 | // hub resolve a host line the same way. Nothing here WRITES that file — |
| 208 | // `wall_host.recordHost` and `webhub_main` do. | 213 | // `wall_host.recordHost` and `webhub_main` do. |
| 209 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 214 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath", "dial" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 210 | // ---- the two fronts ---- | 215 | // ---- the two fronts ---- |
| 211 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, | 216 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, |
| 212 | // WS endpoint naming. Assets are injected (the exe root @embedFiles | 217 | // WS endpoint naming. Assets are injected (the exe root @embedFiles |
| @@ -708,11 +713,10 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 708 | /// of all: it carries every argument parser but muxa's, its mains being | 713 | /// of all: it carries every argument parser but muxa's, its mains being |
| 709 | /// child files — a test that is never built is not a test (decisions.md). | 714 | /// child files — a test that is never built is not a test (decisions.md). |
| 710 | const test_order = [_][]const u8{ | 715 | const test_order = [_][]const u8{ |
| 711 | "script", "cliflags", "testtmp", "spawn", "quic", | 716 | "script", "cliflags", "testtmp", "spawn", "dial", "quic", |
| 712 | "webhub", "agent", "term", "rawmode", "delaypipe", | 717 | "webhub", "agent", "term", "rawmode", "delaypipe", "render", |
| 713 | "render", "wsclient", "ptyclient", "pty", "sockpath", | 718 | "wsclient", "ptyclient", "pty", "sockpath", "xdg", "proxy", |
| 714 | "xdg", "proxy", "wall", "client", "daemon", | 719 | "wall", "client", "daemon", "mux", |
| 715 | "mux", | ||
| 716 | }; | 720 | }; |
| 717 | 721 | ||
| 718 | comptime { | 722 | comptime { |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const sockpath = @import("sockpath"); | |||
| 12 | const quic = @import("quic"); | 12 | const quic = @import("quic"); |
| 13 | const xdg = @import("xdg"); | 13 | const xdg = @import("xdg"); |
| 14 | const cliflags = @import("cliflags"); | 14 | const cliflags = @import("cliflags"); |
| 15 | const dial = @import("dial"); | ||
| 15 | const build_options = @import("build_options"); | 16 | const build_options = @import("build_options"); |
| 16 | 17 | ||
| 17 | const usage = | 18 | const usage = |
| @@ -303,7 +304,7 @@ const AgentConnection = struct { | |||
| 303 | reconnect_failure: ?[]const u8 = null, | 304 | reconnect_failure: ?[]const u8 = null, |
| 304 | 305 | ||
| 305 | fn open(alloc: std.mem.Allocator, sock_path: []const u8) !AgentConnection { | 306 | fn open(alloc: std.mem.Allocator, sock_path: []const u8) !AgentConnection { |
| 306 | const s = try std.net.connectUnixSocket(sock_path); | 307 | const s = try dial.dial(sock_path); |
| 307 | return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; | 308 | return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; |
| 308 | } | 309 | } |
| 309 | 310 | ||
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,10 @@ const TmpDir = @import("testtmp").TmpDir; | |||
| 13 | const quic = @import("quic"); | 13 | const quic = @import("quic"); |
| 14 | const xdg = @import("xdg"); | 14 | const xdg = @import("xdg"); |
| 15 | const sockpath = @import("sockpath"); | 15 | const sockpath = @import("sockpath"); |
| 16 | // The daemon-socket dial, under a name that is not `dial`: `Transport.open` | ||
| 17 | // already takes a `dial: ?*handoff.Dial` parameter, and a parameter that | ||
| 18 | // shadows a file-scope declaration does not compile. | ||
| 19 | const dialer = @import("dial"); | ||
| 16 | 20 | ||
| 17 | // The client link's public seams: the wall, the hub and the mains reach | 21 | // The client link's public seams: the wall, the hub and the mains reach |
| 18 | // these as client.X — the table stays one row, the files stay children. | 22 | // these as client.X — the table stays one row, the files stay children. |
| @@ -470,7 +474,7 @@ pub const Transport = struct { | |||
| 470 | return pipeTransport(try spawnVia(alloc, argv)); | 474 | return pipeTransport(try spawnVia(alloc, argv)); |
| 471 | }, | 475 | }, |
| 472 | .sock => |path| { | 476 | .sock => |path| { |
| 473 | const stream = try std.net.connectUnixSocket(path); | 477 | const stream = try dialer.dial(path); |
| 474 | return .{ | 478 | return .{ |
| 475 | .conn = .{ .r = stream.handle, .w = stream.handle }, | 479 | .conn = .{ .r = stream.handle, .w = stream.handle }, |
| 476 | .link = .fd, | 480 | .link = .fd, |
| @@ -800,7 +804,7 @@ pub const Transport = struct { | |||
| 800 | /// pump can turn into an `agent_close`. | 804 | /// pump can turn into an `agent_close`. |
| 801 | pub fn connectAgent(path: []const u8) ?std.posix.fd_t { | 805 | pub fn connectAgent(path: []const u8) ?std.posix.fd_t { |
| 802 | if (path.len == 0) return null; | 806 | if (path.len == 0) return null; |
| 803 | const stream = std.net.connectUnixSocket(path) catch return null; | 807 | const stream = dialer.dial(path) catch return null; |
| 804 | return stream.handle; | 808 | return stream.handle; |
| 805 | } | 809 | } |
| 806 | 810 | ||
src/dial.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,60 @@ | |||
| 1 | //! The client side of a daemon's unix socket: dial it, and optionally say | ||
| 2 | //! hello. Watermark resumes spell `encodeAttach` themselves — the watermark | ||
| 3 | //! is the caller's story, not this module's. | ||
| 4 | //! | ||
| 5 | //! Connecting a client to a daemon is the operation this product exists to | ||
| 6 | //! perform, so it is a callable primitive rather than four lines every caller | ||
| 7 | //! writes again. It imports `term` for the attach encoders and nothing else, | ||
| 8 | //! which is the point: an embedder that wants to reach a daemon links this | ||
| 9 | //! and `term`, not the client module's transports, hosts file and pane tree. | ||
| 10 | const std = @import("std"); | ||
| 11 | const proto = @import("term").protocol; | ||
| 12 | |||
| 13 | /// The connection alone, with no frame sent. What an observer verb, a probe | ||
| 14 | /// or a client resuming from a watermark wants: the first bytes on the | ||
| 15 | /// socket are then the caller's to choose. | ||
| 16 | pub fn dial(sock_path: []const u8) !std.net.Stream { | ||
| 17 | return std.net.connectUnixSocket(sock_path); | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Dial and attach to the daemon's default session at this size. The | ||
| 21 | /// watermark is (0, 0) because a connection this call just opened holds | ||
| 22 | /// nothing to resume from; a caller with bytes already replayed uses `dial` | ||
| 23 | /// and spells `protocol.encodeAttach` with its own seq and epoch. | ||
| 24 | pub fn dialAttach(sock_path: []const u8, cols: u16, rows: u16) !std.net.Stream { | ||
| 25 | const s = try dial(sock_path); | ||
| 26 | // A connection whose attach failed is ours to close: the handle has not | ||
| 27 | // reached the caller yet, so nobody else can, and the daemon would | ||
| 28 | // otherwise hold a client that never said hello until the process ends. | ||
| 29 | errdefer s.close(); | ||
| 30 | try proto.writeFrame(s.handle, .attach, &proto.encodeAttach(cols, rows, 0, 0)); | ||
| 31 | return s; | ||
| 32 | } | ||
| 33 | |||
| 34 | /// `dialAttach` for a named session. The name goes on the wire VERBATIM — | ||
| 35 | /// this module refuses nothing, because the daemon owns the answer to | ||
| 36 | /// whether a name exists. A caller holding a name a USER spelled runs it | ||
| 37 | /// through `protocol.SessionName.parseCLI` first (which also bounds the | ||
| 38 | /// length `encodeAttachNamed` asserts), and one holding the default | ||
| 39 | /// session's own spelling through `protocol.wireName`, since the wire says | ||
| 40 | /// "default" as the empty tail. | ||
| 41 | pub fn dialAttachNamed(sock_path: []const u8, cols: u16, rows: u16, name: []const u8) !std.net.Stream { | ||
| 42 | const s = try dial(sock_path); | ||
| 43 | errdefer s.close(); | ||
| 44 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 45 | try proto.writeFrame(s.handle, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name)); | ||
| 46 | return s; | ||
| 47 | } | ||
| 48 | |||
| 49 | // A daemon of our own is the server suite's business, not this module's: | ||
| 50 | // what is worth pinning without one is that a path nobody bound fails as a | ||
| 51 | // dial — an error the caller can report — rather than blocking or reaching | ||
| 52 | // the attach write with a handle it never got. | ||
| 53 | test "a path nothing is bound at fails the dial" { | ||
| 54 | try std.testing.expectError(error.FileNotFound, dial("/nonexistent-dir/mux-dial-test.sock")); | ||
| 55 | } | ||
| 56 | |||
| 57 | test "the attach helpers fail at the dial, before any frame" { | ||
| 58 | try std.testing.expectError(error.FileNotFound, dialAttach("/nonexistent-dir/mux-dial-test.sock", 80, 24)); | ||
| 59 | try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work")); | ||
| 60 | } | ||