a73x

8860165d

Add dial: the client side of a daemon's socket

a73x   2026-08-31 15:22

Commit message
Add dial: the client side of a daemon's socket

Connecting a client to a daemon is the operation this product exists to
perform, and until now no callable primitive said so: every caller spelled
`std.net.connectUnixSocket` and, if it wanted a session, the attach frame
after it. `src/dial.zig` is that primitive — dial, dial+attach, dial+attach
by name — and the watermark stays out of it, because a resume is the
caller's story and a connection these helpers just opened has nothing to
resume from.

It lives at the root and imports `term` alone. `term` owns the attach
encoders and knows no socket; the client module knows sockets but carries
2.7k lines of transports, hosts file and pane tree with it. An embedder that
wants to reach a daemon should link the wire contract and forty lines, not
the CLI client.

Three production sites now call it: `Transport.open`'s `.sock` arm and
`connectAgent` in client.zig — under the name `dialer` there, since
`Transport.open` already takes a `dial: ?*handoff.Dial` and a parameter that
shadows a file-scope declaration does not compile — and `AgentConnection.open`'s
unix arm in muxa.zig. `proxy.zig` keeps its bare connect on purpose: it
carries opaque bytes and `term` stays out of its imports. `sockpath`'s
probes keep theirs too — they discard the fd, their subject being bind-side
refusal rather than a connection.

The daemon's tests, which hand-roll dial+attach 175 times, adopt it next;
the row's `test_import` grant lands with the first of those imports, since
`checkGrantsUsed` refuses a grant no file spends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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 }