repos / zmx

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

commit
9a25a8a
parent
14d6858
author
Eric Bower
date
2025-10-11 16:39:18 -0400 EDT
refactor: move unix ipc protocol to separate module
2 files changed,  +316, -1
M specs/protocol.md
+18, -1
 1@@ -8,7 +8,24 @@ The communication occurs over a Unix domain socket. The path to the socket is co
 2 
 3 ## Serialization
 4 
 5-All messages are serialized using JSON. Each message is a JSON object, and messages are separated by a newline character (`\n`). This allows for simple streaming and parsing of messages.
 6+All messages are currently serialized using **newline-delimited JSON (NDJSON)**. Each message is a JSON object terminated by a newline character (`\n`). This allows for simple streaming and parsing of messages while maintaining human-readable logs for debugging.
 7+
 8+### Implementation
 9+
10+The protocol implementation is centralized in `src/protocol.zig`, which provides:
11+- Typed message structs for all payloads
12+- `MessageType` enum for type-safe dispatching
13+- Helper functions: `writeJson()`, `parseMessage()`, `parseMessageType()`
14+- `LineBuffer` for efficient NDJSON line buffering
15+
16+### Future: Binary Frame Support
17+
18+The protocol module includes infrastructure for future binary framing to optimize PTY data throughput:
19+- Frame format: `[4-byte length][2-byte type][payload...]`
20+- Type 1: JSON control messages (current)
21+- Type 2: Binary PTY data (future optimization)
22+
23+This hybrid approach would keep control messages in human-readable JSON while allowing raw binary PTY data when profiling shows JSON is a bottleneck.
24 
25 ## Message Structure
26 
A src/protocol.zig
+298, -0
  1@@ -0,0 +1,298 @@
  2+const std = @import("std");
  3+const posix = std.posix;
  4+
  5+// Message types enum for type-safe dispatching
  6+pub const MessageType = enum {
  7+    // Client -> Daemon requests
  8+    attach_session_request,
  9+    detach_session_request,
 10+    kill_session_request,
 11+    list_sessions_request,
 12+    pty_in,
 13+    window_resize,
 14+
 15+    // Daemon -> Client responses
 16+    attach_session_response,
 17+    detach_session_response,
 18+    kill_session_response,
 19+    list_sessions_response,
 20+    pty_out,
 21+
 22+    // Daemon -> Client notifications
 23+    detach_notification,
 24+    kill_notification,
 25+
 26+    pub fn toString(self: MessageType) []const u8 {
 27+        return switch (self) {
 28+            .attach_session_request => "attach_session_request",
 29+            .detach_session_request => "detach_session_request",
 30+            .kill_session_request => "kill_session_request",
 31+            .list_sessions_request => "list_sessions_request",
 32+            .pty_in => "pty_in",
 33+            .window_resize => "window_resize",
 34+            .attach_session_response => "attach_session_response",
 35+            .detach_session_response => "detach_session_response",
 36+            .kill_session_response => "kill_session_response",
 37+            .list_sessions_response => "list_sessions_response",
 38+            .pty_out => "pty_out",
 39+            .detach_notification => "detach_notification",
 40+            .kill_notification => "kill_notification",
 41+        };
 42+    }
 43+
 44+    pub fn fromString(s: []const u8) ?MessageType {
 45+        const map = std.StaticStringMap(MessageType).initComptime(.{
 46+            .{ "attach_session_request", .attach_session_request },
 47+            .{ "detach_session_request", .detach_session_request },
 48+            .{ "kill_session_request", .kill_session_request },
 49+            .{ "list_sessions_request", .list_sessions_request },
 50+            .{ "pty_in", .pty_in },
 51+            .{ "window_resize", .window_resize },
 52+            .{ "attach_session_response", .attach_session_response },
 53+            .{ "detach_session_response", .detach_session_response },
 54+            .{ "kill_session_response", .kill_session_response },
 55+            .{ "list_sessions_response", .list_sessions_response },
 56+            .{ "pty_out", .pty_out },
 57+            .{ "detach_notification", .detach_notification },
 58+            .{ "kill_notification", .kill_notification },
 59+        });
 60+        return map.get(s);
 61+    }
 62+};
 63+
 64+// Typed payload structs for requests
 65+pub const AttachSessionRequest = struct {
 66+    session_name: []const u8,
 67+};
 68+
 69+pub const DetachSessionRequest = struct {
 70+    session_name: []const u8,
 71+    client_fd: ?i64 = null,
 72+};
 73+
 74+pub const KillSessionRequest = struct {
 75+    session_name: []const u8,
 76+};
 77+
 78+pub const ListSessionsRequest = struct {};
 79+
 80+pub const PtyInput = struct {
 81+    text: []const u8,
 82+};
 83+
 84+pub const WindowResize = struct {
 85+    rows: u16,
 86+    cols: u16,
 87+};
 88+
 89+// Typed payload structs for responses
 90+pub const SessionInfo = struct {
 91+    name: []const u8,
 92+    status: []const u8,
 93+    clients: i64,
 94+    created_at: []const u8,
 95+};
 96+
 97+pub const AttachSessionResponse = struct {
 98+    status: []const u8,
 99+    client_fd: ?i64 = null,
100+    error_message: ?[]const u8 = null,
101+};
102+
103+pub const DetachSessionResponse = struct {
104+    status: []const u8,
105+    error_message: ?[]const u8 = null,
106+};
107+
108+pub const KillSessionResponse = struct {
109+    status: []const u8,
110+    error_message: ?[]const u8 = null,
111+};
112+
113+pub const ListSessionsResponse = struct {
114+    status: []const u8,
115+    sessions: []SessionInfo = &.{},
116+    error_message: ?[]const u8 = null,
117+};
118+
119+pub const PtyOutput = struct {
120+    text: []const u8,
121+};
122+
123+pub const DetachNotification = struct {
124+    session_name: []const u8,
125+};
126+
127+pub const KillNotification = struct {
128+    session_name: []const u8,
129+};
130+
131+// Generic message wrapper
132+pub fn Message(comptime T: type) type {
133+    return struct {
134+        type: []const u8,
135+        payload: T,
136+    };
137+}
138+
139+// Helper to write a JSON message to a file descriptor
140+pub fn writeJson(allocator: std.mem.Allocator, fd: posix.fd_t, msg_type: MessageType, payload: anytype) !void {
141+    var out: std.io.Writer.Allocating = .init(allocator);
142+    defer out.deinit();
143+
144+    const msg = Message(@TypeOf(payload)){
145+        .type = msg_type.toString(),
146+        .payload = payload,
147+    };
148+
149+    try std.json.stringify(msg, .{}, &out.writer);
150+    try out.writer.writeByte('\n');
151+
152+    _ = try posix.write(fd, out.written());
153+}
154+
155+// Helper to write a raw JSON response (for complex cases like list_sessions with dynamic arrays)
156+pub fn writeJsonRaw(fd: posix.fd_t, json_str: []const u8) !void {
157+    _ = try posix.write(fd, json_str);
158+}
159+
160+// Helper to parse a JSON message from a line
161+pub fn parseMessage(comptime T: type, allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(Message(T)) {
162+    return try std.json.parseFromSlice(
163+        Message(T),
164+        allocator,
165+        line,
166+        .{ .ignore_unknown_fields = true },
167+    );
168+}
169+
170+// Helper to parse just the message type from a line (for dispatching)
171+pub fn parseMessageType(allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(struct { type: []const u8 }) {
172+    return try std.json.parseFromSlice(
173+        struct { type: []const u8 },
174+        allocator,
175+        line,
176+        .{ .ignore_unknown_fields = true },
177+    );
178+}
179+
180+// NDJSON line buffering helper
181+pub const LineBuffer = struct {
182+    buffer: std.ArrayList(u8),
183+
184+    pub fn init(allocator: std.mem.Allocator) LineBuffer {
185+        return .{ .buffer = std.ArrayList(u8).init(allocator) };
186+    }
187+
188+    pub fn deinit(self: *LineBuffer) void {
189+        self.buffer.deinit();
190+    }
191+
192+    // Append new data and return an iterator over complete lines
193+    pub fn appendData(self: *LineBuffer, data: []const u8) !LineIterator {
194+        try self.buffer.appendSlice(data);
195+        return LineIterator{ .buffer = &self.buffer };
196+    }
197+
198+    pub const LineIterator = struct {
199+        buffer: *std.ArrayList(u8),
200+        offset: usize = 0,
201+
202+        pub fn next(self: *LineIterator) ?[]const u8 {
203+            if (self.offset >= self.buffer.items.len) return null;
204+
205+            const remaining = self.buffer.items[self.offset..];
206+            const newline_idx = std.mem.indexOf(u8, remaining, "\n") orelse return null;
207+
208+            const line = remaining[0..newline_idx];
209+            self.offset += newline_idx + 1;
210+            return line;
211+        }
212+
213+        // Call this after iteration to remove processed lines
214+        pub fn compact(self: *LineIterator) void {
215+            if (self.offset > 0) {
216+                const remaining = self.buffer.items[self.offset..];
217+                std.mem.copyForwards(u8, self.buffer.items, remaining);
218+                self.buffer.shrinkRetainingCapacity(remaining.len);
219+            }
220+        }
221+    };
222+};
223+
224+// Future: Binary frame support for PTY data
225+// This infrastructure allows us to add binary framing later without breaking existing code
226+pub const FrameType = enum(u16) {
227+    json_control = 1, // JSON-encoded control messages (current protocol)
228+    pty_binary = 2, // Raw PTY bytes (future optimization)
229+};
230+
231+pub const FrameHeader = packed struct {
232+    length: u32, // little-endian, total payload length
233+    frame_type: u16, // little-endian, FrameType value
234+};
235+
236+// Future: Helper to write a binary frame (not used yet)
237+pub fn writeBinaryFrame(fd: posix.fd_t, frame_type: FrameType, payload: []const u8) !void {
238+    const header = FrameHeader{
239+        .length = @intCast(payload.len),
240+        .frame_type = @intFromEnum(frame_type),
241+    };
242+
243+    const header_bytes = std.mem.asBytes(&header);
244+    _ = try posix.write(fd, header_bytes);
245+    _ = try posix.write(fd, payload);
246+}
247+
248+// Future: Helper to read a binary frame (not used yet)
249+pub fn readBinaryFrame(allocator: std.mem.Allocator, fd: posix.fd_t) !struct { frame_type: FrameType, payload: []u8 } {
250+    var header_bytes: [@sizeOf(FrameHeader)]u8 = undefined;
251+    const read_len = try posix.read(fd, &header_bytes);
252+    if (read_len != @sizeOf(FrameHeader)) return error.IncompleteFrame;
253+
254+    const header: *const FrameHeader = @alignCast(@ptrCast(&header_bytes));
255+    const payload = try allocator.alloc(u8, header.length);
256+    errdefer allocator.free(payload);
257+
258+    const payload_read = try posix.read(fd, payload);
259+    if (payload_read != header.length) return error.IncompleteFrame;
260+
261+    return .{
262+        .frame_type = @enumFromInt(header.frame_type),
263+        .payload = payload,
264+    };
265+}
266+
267+// Tests
268+test "MessageType string conversion" {
269+    const attach = MessageType.attach_session_request;
270+    try std.testing.expectEqualStrings("attach_session_request", attach.toString());
271+
272+    const parsed = MessageType.fromString("attach_session_request");
273+    try std.testing.expect(parsed != null);
274+    try std.testing.expectEqual(MessageType.attach_session_request, parsed.?);
275+}
276+
277+test "LineBuffer iteration" {
278+    const allocator = std.testing.allocator;
279+    var buf = LineBuffer.init(allocator);
280+    defer buf.deinit();
281+
282+    var iter = try buf.appendData("line1\nline2\n");
283+    try std.testing.expectEqualStrings("line1", iter.next().?);
284+    try std.testing.expectEqualStrings("line2", iter.next().?);
285+    try std.testing.expect(iter.next() == null);
286+    iter.compact();
287+
288+    // Incomplete line should remain
289+    iter = try buf.appendData("incomplete");
290+    try std.testing.expect(iter.next() == null);
291+    iter.compact();
292+    try std.testing.expectEqual(10, buf.buffer.items.len);
293+
294+    // Complete the line
295+    iter = try buf.appendData(" line\n");
296+    try std.testing.expectEqualStrings("incomplete line", iter.next().?);
297+    iter.compact();
298+    try std.testing.expectEqual(0, buf.buffer.items.len);
299+}