repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

zmx / src
Eric Bower  ·  2026-03-23

socket.zig

  1const std = @import("std");
  2const posix = std.posix;
  3
  4pub fn getSeshPrefix() []const u8 {
  5    return std.posix.getenv("ZMX_SESSION_PREFIX") orelse "";
  6}
  7
  8pub fn getSeshNameFromEnv() []const u8 {
  9    return std.posix.getenv("ZMX_SESSION") orelse "";
 10}
 11
 12pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
 13    const prefix = getSeshPrefix();
 14    if (prefix.len == 0 and sesh.len == 0) {
 15        return error.SessionNameRequired;
 16    }
 17    const full = try std.fmt.allocPrint(alloc, "{s}{s}", .{ prefix, sesh });
 18    // Session names become filenames under socket_dir. Rejecting path
 19    // separators and dot-dot prevents socket creation and stale-socket
 20    // deletion from operating outside that directory.
 21    if (std.mem.indexOfScalar(u8, full, '/') != null or
 22        std.mem.indexOfScalar(u8, full, 0) != null or
 23        std.mem.eql(u8, full, ".") or std.mem.eql(u8, full, ".."))
 24    {
 25        alloc.free(full);
 26        return error.InvalidSessionName;
 27    }
 28    return full;
 29}
 30
 31pub fn sessionConnect(sesh: []const u8) !i32 {
 32    var unix_addr = try std.net.Address.initUnix(sesh);
 33    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
 34    errdefer posix.close(socket_fd);
 35    try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
 36    return socket_fd;
 37}
 38
 39pub fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
 40    std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
 41    dir.deleteFile(session_name) catch |err| {
 42        std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
 43    };
 44}
 45
 46pub fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
 47    const stat = dir.statFile(name) catch |err| switch (err) {
 48        error.FileNotFound => return false,
 49        else => return err,
 50    };
 51    if (stat.kind != .unix_domain_socket) {
 52        return error.FileNotUnixSocket;
 53    }
 54    return true;
 55}
 56
 57pub fn createSocket(fname: []const u8) !i32 {
 58    // AF.UNIX: Unix domain socket for local IPC with client processes
 59    // SOCK.STREAM: Reliable, bidirectional communication
 60    // SOCK.NONBLOCK: Set socket to non-blocking
 61    const fd = try posix.socket(
 62        posix.AF.UNIX,
 63        posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC,
 64        0,
 65    );
 66    errdefer posix.close(fd);
 67
 68    var unix_addr = try std.net.Address.initUnix(fname);
 69    try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
 70    try posix.listen(fd, 128);
 71    return fd;
 72}
 73
 74/// Maximum number of usable bytes in a Unix domain socket path.
 75/// Derived from the platform's sockaddr_un.path field, minus 1 for the
 76/// required null terminator.
 77pub const max_socket_path_len: usize = @typeInfo(
 78    @TypeOf(@as(posix.sockaddr.un, undefined).path),
 79).array.len - 1;
 80
 81pub fn getSocketPath(
 82    alloc: std.mem.Allocator,
 83    socket_dir: []const u8,
 84    session_name: []const u8,
 85) error{ NameTooLong, OutOfMemory }![]const u8 {
 86    const dir = socket_dir;
 87    const path_len = dir.len + 1 + session_name.len;
 88    if (path_len > max_socket_path_len) return error.NameTooLong;
 89    const fname = try alloc.alloc(u8, path_len);
 90    @memcpy(fname[0..dir.len], dir);
 91    @memcpy(fname[dir.len .. dir.len + 1], "/");
 92    @memcpy(fname[dir.len + 1 ..], session_name);
 93    return fname;
 94}
 95
 96pub fn printSessionNameTooLong(session_name: []const u8, socket_dir: []const u8) void {
 97    var buf: [4096]u8 = undefined;
 98    var w = std.fs.File.stderr().writer(&buf);
 99    if (maxSessionNameLen(socket_dir)) |max_len| {
100        w.interface.print(
101            "error: session name is too long ({d} bytes, max {d} for socket directory \"{s}\")\n",
102            .{ session_name.len, max_len, socket_dir },
103        ) catch {};
104    } else {
105        w.interface.print(
106            "error: socket directory path is too long (\"{s}\")\n",
107            .{socket_dir},
108        ) catch {};
109    }
110    w.interface.flush() catch {};
111}
112
113/// Returns the maximum session name length for a given socket directory,
114/// or null if the socket directory itself is already too long.
115pub fn maxSessionNameLen(socket_dir: []const u8) ?usize {
116    // path = socket_dir + "/" + session_name
117    const overhead = socket_dir.len + 1;
118    if (overhead >= max_socket_path_len) return null;
119    return max_socket_path_len - overhead;
120}
121
122test "max_socket_path_len matches platform sockaddr_un" {
123    const path_field_len = @typeInfo(
124        @TypeOf(@as(posix.sockaddr.un, undefined).path),
125    ).array.len;
126    try std.testing.expectEqual(path_field_len - 1, max_socket_path_len);
127    try std.testing.expect(max_socket_path_len > 0);
128}
129
130test "getSocketPath succeeds for paths within limit" {
131    const alloc = std.testing.allocator;
132    const result = try getSocketPath(alloc, "/tmp/zmx", "mysession");
133    defer alloc.free(result);
134    try std.testing.expectEqualStrings("/tmp/zmx/mysession", result);
135}
136
137test "getSocketPath returns NameTooLong when path exceeds limit" {
138    const alloc = std.testing.allocator;
139    const dir = [_]u8{'d'} ** (max_socket_path_len - 2);
140    const dir_slice: []const u8 = &dir;
141
142    const ok = try getSocketPath(alloc, dir_slice, "x");
143    defer alloc.free(ok);
144    try std.testing.expectEqual(max_socket_path_len, ok.len);
145
146    const err = getSocketPath(alloc, dir_slice, "xx");
147    try std.testing.expectError(error.NameTooLong, err);
148}
149
150test "getSocketPath returns NameTooLong for empty dir with oversized name" {
151    const alloc = std.testing.allocator;
152    const name = [_]u8{'n'} ** (max_socket_path_len);
153    const name_slice: []const u8 = &name;
154    const err = getSocketPath(alloc, "", name_slice);
155    try std.testing.expectError(error.NameTooLong, err);
156}
157
158test "maxSessionNameLen computes correct dynamic limit" {
159    const short_dir = "/tmp/zmx";
160    const short_max = maxSessionNameLen(short_dir).?;
161    try std.testing.expectEqual(max_socket_path_len - short_dir.len - 1, short_max);
162
163    const full_dir = [_]u8{'f'} ** max_socket_path_len;
164    const full_dir_slice: []const u8 = &full_dir;
165    try std.testing.expectEqual(@as(?usize, null), maxSessionNameLen(full_dir_slice));
166
167    const tight_dir = [_]u8{'t'} ** (max_socket_path_len - 2);
168    const tight_dir_slice: []const u8 = &tight_dir;
169    try std.testing.expectEqual(@as(?usize, 1), maxSessionNameLen(tight_dir_slice));
170}
171
172test "getSocketPath boundary: name fills exactly to limit" {
173    const alloc = std.testing.allocator;
174    const dir = "/tmp/zmx";
175    const max_name_len = maxSessionNameLen(dir).?;
176
177    const name_at_limit = try alloc.alloc(u8, max_name_len);
178    defer alloc.free(name_at_limit);
179    @memset(name_at_limit, 'a');
180
181    const path = try getSocketPath(alloc, dir, name_at_limit);
182    defer alloc.free(path);
183    try std.testing.expectEqual(max_socket_path_len, path.len);
184
185    const name_over_limit = try alloc.alloc(u8, max_name_len + 1);
186    defer alloc.free(name_over_limit);
187    @memset(name_over_limit, 'b');
188
189    try std.testing.expectError(error.NameTooLong, getSocketPath(alloc, dir, name_over_limit));
190}