- 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
+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
+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+}