src/dial.zig
Ref: Size: 8.5 KiB History
//! The client side of a daemon's unix socket: dial it, and optionally say
//! hello. Watermark resumes spell `encodeAttach` themselves — the watermark
//! is the caller's story, not this module's.
//!
//! Connecting a client to a daemon is the operation this product exists to
//! perform, so it is a callable primitive rather than four lines every caller
//! writes again. It imports `term` for the attach encoders, `link` for the
//! one round trip's wait and `sockpath` for the connect itself, and nothing
//! else: an embedder that wants to reach a daemon links those three, not the
//! client module's transports, hosts file and pane tree. `sockpath` is here
//! rather than a bare `std.net.connectUnixSocket` because the two kernels
//! disagree about a path that is not a socket and one of them turns it into a
//! panic; `sockpath.connectSocket` says which and why.
const std = @import("std");
const proto = @import("term").protocol;
const link_mod = @import("link");
const sockpath = @import("sockpath");
/// The connection alone, with no frame sent. What an observer verb, a probe
/// or a client resuming from a watermark wants: the first bytes on the
/// socket are then the caller's to choose.
pub fn dial(sock_path: []const u8) !std.net.Stream {
return sockpath.connectSocket(sock_path);
}
/// Dial and attach to the daemon's default session at this size. The
/// watermark is (0, 0) because a connection this call just opened holds
/// nothing to resume from; a caller with bytes already replayed uses `dial`
/// and spells `protocol.encodeAttach` with its own seq and epoch.
pub fn dialAttach(sock_path: []const u8, cols: u16, rows: u16) !std.net.Stream {
const s = try dial(sock_path);
// A connection whose attach failed is ours to close: the handle has not
// reached the caller yet, so nobody else can, and the daemon would
// otherwise hold a client that never said hello until the process ends.
errdefer s.close();
try proto.writeFrame(s.handle, .attach, &proto.encodeAttach(cols, rows, 0, 0));
return s;
}
/// `dialAttach` for a named session. The name goes on the wire VERBATIM —
/// this module refuses nothing, because the daemon owns the answer to
/// whether a name exists. A caller holding a name a USER spelled runs it
/// through `protocol.SessionName.parseCLI` first (which also bounds the
/// length `encodeAttachNamed` asserts), and one holding the default
/// session's own spelling through `protocol.wireName`, since the wire says
/// "default" as the empty tail.
pub fn dialAttachNamed(sock_path: []const u8, cols: u16, rows: u16, name: []const u8) !std.net.Stream {
const s = try dial(sock_path);
errdefer s.close();
var buf: [proto.attach_max_len]u8 = undefined;
try proto.writeFrame(s.handle, .attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, name));
return s;
}
/// The goodbye for a connection this module opened. Fire-and-forget by
/// design: there is no acknowledgement on the wire. The daemon answers a
/// `detach` by dropping the client and sending nothing back, and a client
/// that has said goodbye never redials — so the close that follows is final
/// rather than a network event to recover from. (The wall's `detach_ack` is
/// an intra-process atomic its pump sets for its own thread-join ordering.
/// No frame carries it.)
///
/// This takes the fd rather than the `std.net.Stream`, because that is what
/// a caller which kept only the handle has, and every connection this module
/// hands back is one. Which is also the limit of its reach: the client's own
/// detach goes through `client.Transport` and `mux a`'s through that module's
/// agent connection, and either of those may be a QUIC link with no fd to
/// write to. Those carriers frame the same goodbye themselves; a wrapper over
/// a file descriptor is not what they hold.
pub fn detach(fd: std.posix.fd_t) !void {
return proto.writeFrame(fd, .detach, "");
}
/// Dial, ask one question, read the one answer, hang up. The observer verbs'
/// round trip: `stats_req`, `sessions_req`, `debug_dump`, `endpoint_req`,
/// `upgrade_req` — every one of them a question asked by a caller that holds
/// no other connection to that daemon and wants none after the answer.
///
/// Null is "no answer": the deadline ran out, or the peer closed without
/// sending one. A read that fails mid-wait — ECONNRESET on a daemon that
/// died with the question in flight — folds into that same null, where the
/// hand-rolled loop this replaced propagated the errno. Deliberate: the
/// caller's report is "the daemon never answered" either way, and one
/// wording for one fact beats two that must be kept in step.
/// A null `deadline_ms` waits forever, which is what a caller
/// wants when a daemon that has stopped replying should be a visible hang
/// rather than a report of an absent socket.
///
/// Two failures are named so the caller does not have to tell them apart from
/// a connect errno set: `error.NoDaemon` is the dial, and
/// `error.RequestNotSent` is a peer that closed between the connect and the
/// write. Both mean the daemon never heard the question, and every caller has
/// its own words for that — which is why this returns the distinction instead
/// of printing it. Any other error is a reply this side could not read, and
/// stays an error precisely so a corrupt frame is never reported as silence.
pub fn ask(
alloc: std.mem.Allocator,
sock_path: []const u8,
req: proto.MsgType,
payload: []const u8,
want: proto.MsgType,
deadline_ms: ?u32,
) !?proto.Frame {
const s = dial(sock_path) catch return error.NoDaemon;
// The Link owns the fd from here: its close() is the one that runs.
var l: link_mod.Link = .{ .fd = s.handle };
defer l.close();
// A request that could not be delivered is its own answer: the caller
// reports a daemon that never heard the question differently from one
// that heard it and said nothing.
l.sendFrame(req, payload) catch return error.RequestNotSent;
// A peer that closed without answering is the same "no answer" as a
// deadline that ran out — dial.ask's contract predates the Link and
// keeps it; callers that need the distinction hold a Link themselves.
// The null sink is the observer-verb policy: every frame that is not the
// wanted one is dropped, which is right for a socket opened to ask one
// question and wrong for a client connection carrying a snapshot and its
// deltas.
return l.awaitFrame(alloc, want, deadline_ms, .{}) catch |e| switch (e) {
error.Closed => null,
else => e,
};
}
// A daemon of our own is the server suite's business, not this module's:
// what is worth pinning without one is that a path nobody bound fails as a
// dial — an error the caller can report — rather than blocking or reaching
// the attach write with a handle it never got.
test "a path nothing is bound at fails the dial" {
try std.testing.expectError(error.FileNotFound, dial("/nonexistent-dir/mux-dial-test.sock"));
}
test "the attach helpers fail at the dial, before any frame" {
try std.testing.expectError(error.FileNotFound, dialAttach("/nonexistent-dir/mux-dial-test.sock", 80, 24));
try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work"));
}
test "detach writes one empty frame and waits for nothing" {
// Pinned on a socketpair because the goodbye has no reply to wait for:
// what is checkable is that exactly one empty `detach` reaches the peer
// and the call returns without reading anything back.
var pair: [2]std.posix.fd_t = undefined;
try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
defer std.posix.close(pair[1]);
try detach(pair[0]);
// Close the writing end — not deferred, since it must be shut before the
// reads below — so a second frame would show up as a second read rather
// than as a block.
std.posix.close(pair[0]);
const alloc = std.testing.allocator;
const frame = (try proto.readFrame(alloc, pair[1])).?;
defer frame.deinit(alloc);
try std.testing.expectEqual(proto.MsgType.detach, frame.type);
try std.testing.expectEqual(@as(usize, 0), frame.payload.len);
try std.testing.expect((try proto.readFrame(alloc, pair[1])) == null);
}
test "ask: a path nothing is bound at is error.NoDaemon, not a connect errno" {
try std.testing.expectError(
error.NoDaemon,
ask(std.testing.allocator, "/nonexistent-dir/mux-dial-test.sock", .stats_req, "", .stats_reply, 100),
);
}