Commit 35b8783

Eric Bower  ·  2026-10-16 10:42:05 -0400 EDT
parent 3414f1a
refactor(ipc): freeze ipc wire protocol and align struct layouts

Harden the unix domain socket IPC wire ABI in prep for libzmx embedding.

BREAKING CHANGE: existing daemons must be killed before upgrading next version of zmx

Link: https://github.com/neurosnap/zmx/issues/127
Link: https://github.com/neurosnap/zmx/pull/271
Link: https://github.com/neurosnap/zmx/issues/122
4 files changed,  +70, -67
+11, -0
......@@ -4,16 +4,27 @@ Use spec: https://common-changelog.org/
44
55 ## Staged
66
7+### Breaking
8+
9+- Standardized IPC header and payloads to 8-byte aligned `extern struct` layouts. Daemons and clients must be on the same version
10+- Dropped legacy pre-0.7.0 resize framing
11+
712 ### Added
813
914 - Completions for [`yash`](https://magicant.github.io/yash/)
1015 - History command now accepts --screen (print only active screen) and --scrollback (num of rows to print)
1116
17+### Changed
18+
19+- IPC messages now carry an explicity `protocol_version` field
20+- The daemon now responds with `Tag.Unsupported` (255) when receiving unknown IPC tags
21+
1222 ### Fixed
1323
1424 - Stop classifying complete kitty keyboard status replies (`CSI ? flags u`) as user input for leader selection
1525 - Prevent infinite loop in `mkdirAll` when directory path contains a dangling symlink
1626 - Prevent terminal and kitty keyboard queries from taking leadership control
27+- Fixed missing terminal snapshot when attaching to a session originally started detached or via `zmx run`
1728
1829 ## v0.8.1 - 2026-09-05
1930
+35, -58
......@@ -3,6 +3,8 @@ const cross = @import("cross.zig");
33 const socket = @import("socket.zig");
44 const lib_posix = @import("posix.zig");
55
6+pub const PROTOCOL_VERSION: u8 = 1;
7+
68 pub const Tag = enum(u8) {
79 Input = 0,
810 Output = 1,
......@@ -29,6 +31,7 @@ pub const Tag = enum(u8) {
2931 /// Scoped history: the active screen plus a bounded number of
3032 /// preceding scrollback rows. Request payload is `Capture`.
3133 Capture = 22,
34+ Unsupported = 255,
3235 // Non-exhaustive: this enum comes off the wire via bytesToValue and
3336 // @enumFromInt, so out-of-range values are representable
3437 // rather than UB. Switches must handle `_` (unknown tag).
......@@ -41,27 +44,35 @@ comptime {
4144 );
4245 }
4346
44-pub const Header = packed struct {
47+pub const Header = extern struct {
4548 tag: Tag,
49+ protocol_version: u8 = PROTOCOL_VERSION,
50+ _reserved: u16 = 0,
4651 len: u32,
4752 };
4853
49-pub const Resize = packed struct {
54+pub const Resize = extern struct {
5055 rows: u16,
5156 cols: u16,
5257 xpixel: u16 = 0,
5358 ypixel: u16 = 0,
5459
5560 pub fn winsize(self: Resize) cross.c.struct_winsize {
56- return .{ .ws_row = self.rows, .ws_col = self.cols, .ws_xpixel = self.xpixel, .ws_ypixel = self.ypixel };
61+ return .{
62+ .ws_row = self.rows,
63+ .ws_col = self.cols,
64+ .ws_xpixel = self.xpixel,
65+ .ws_ypixel = self.ypixel,
66+ };
5767 }
5868 };
5969
6070 /// Request payload for `Tag.Capture`. `format` is a `util.HistoryFormat`
6171 /// and `rows` is how many scrollback rows to include above the active
6272 /// screen; zero returns only the screen.
63-pub const Capture = packed struct {
73+pub const Capture = extern struct {
6474 format: u8,
75+ _reserved: [3]u8 = .{0} ** 3,
6576 rows: u32,
6677 };
6778
......@@ -86,6 +97,7 @@ pub fn getTerminalSize(fd: i32) Resize {
8697 return .{ .rows = 24, .cols = 120 };
8798 }
8899
100+pub const MAX_PAYLOAD_LEN: usize = 64 * 1024 * 1024;
89101 pub const MAX_CMD_LEN = 256;
90102 pub const MAX_CWD_LEN = 256;
91103
......@@ -102,16 +114,10 @@ pub const Info = extern struct {
102114 created_at: u64,
103115 task_ended_at: u64,
104116 task_exit_code: u8,
117+ protocol_version: u8 = PROTOCOL_VERSION,
118+ _reserved: [6]u8 = .{0} ** 6,
105119 };
106120
107-pub fn expectedLength(data: []const u8) ?usize {
108- if (data.len < @sizeOf(Header)) return null;
109- const header = std.mem.bytesToValue(Header, data[0..@sizeOf(Header)]);
110- // header.len comes off the wire; widen to usize before adding so a
111- // near-u32-max value can't wrap (panic in safe mode, UB in release).
112- return @as(usize, @sizeOf(Header)) + @as(usize, header.len);
113-}
114-
115121 pub fn send(fd: i32, tag: Tag, data: []const u8) !void {
116122 const header = Header{
117123 .tag = tag,
......@@ -143,23 +149,6 @@ pub fn appendMessage(
143149 }
144150 }
145151
146-/// Pre-0.7.0 daemons expect a 4-byte Init/Resize payload (rows+cols, no
147-/// pixel size) and silently drop the 8-byte form, hanging `zmx attach`
148-/// against a running old daemon (#211). Append both encodings: every daemon
149-/// drops the length it doesn't expect and processes the other exactly once.
150-pub const LEGACY_RESIZE_LEN = 4;
151-
152-pub fn appendSizeMessage(
153- alloc: std.mem.Allocator,
154- list: *std.ArrayList(u8),
155- tag: Tag,
156- size: Resize,
157-) !void {
158- const bytes = std.mem.asBytes(&size);
159- try appendMessage(alloc, list, tag, bytes);
160- try appendMessage(alloc, list, tag, bytes[0..LEGACY_RESIZE_LEN]);
161-}
162-
163152 fn writeAll(fd: i32, data: []const u8) !void {
164153 var index: usize = 0;
165154 while (index < data.len) {
......@@ -231,12 +220,21 @@ pub const SocketBuffer = struct {
231220 /// valid until the following `next()` (or `deinit`).
232221 pub fn next(self: *SocketBuffer) ?SocketMsg {
233222 const available = self.buf.items[self.head..];
234- const total = expectedLength(available) orelse return null;
235- if (available.len < total) return null;
223+ if (available.len < @sizeOf(Header)) return null;
236224
237225 const hdr = std.mem.bytesToValue(Header, available[0..@sizeOf(Header)]);
238- const pay = available[@sizeOf(Header)..total];
226+ if (hdr.len > MAX_PAYLOAD_LEN or hdr.protocol_version != PROTOCOL_VERSION) {
227+ // framing error / corrupted stream: clear buffer to avoid
228+ // infinite alloc
229+ self.buf.clearRetainingCapacity();
230+ self.head = 0;
231+ return null;
232+ }
239233
234+ const total = @sizeOf(Header) + @as(usize, hdr.len);
235+ if (available.len < total) return null;
236+
237+ const pay = available[@sizeOf(Header)..total];
240238 self.head += total;
241239 return .{ .header = hdr, .payload = pay };
242240 }
......@@ -261,6 +259,7 @@ const SessionProbeError = error{
261259 ConnectionRefused,
262260 Unexpected,
263261 InfoSizeMismatch,
262+ Unsupported,
264263 };
265264
266265 const SessionProbeResult = struct {
......@@ -343,8 +342,9 @@ pub fn probeSession(
343342 // Need to remove one? → don't. Reserve the integer, stop sending it.
344343 test "Info wire size is frozen" {
345344 try std.testing.expectEqual(@as(usize, 552), @sizeOf(Info));
346- // packed struct{u8,u32} backs to u40 → @sizeOf rounds to 8, not 5.
347345 try std.testing.expectEqual(@as(usize, 8), @sizeOf(Header));
346+ try std.testing.expectEqual(@as(usize, 8), @sizeOf(Resize));
347+ try std.testing.expectEqual(@as(usize, 8), @sizeOf(Capture));
348348 }
349349
350350 test "Tag wire values are frozen" {
......@@ -356,7 +356,7 @@ test "Tag wire values are frozen" {
356356 .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
357357 .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 },
358358 .{ Tag.Send, 18 }, .{ Tag.EnvGet, 19 }, .{ Tag.EnvSet, 20 },
359- .{ Tag.EnvData, 21 }, .{ Tag.Capture, 22 },
359+ .{ Tag.EnvData, 21 }, .{ Tag.Capture, 22 }, .{ Tag.Unsupported, 255 },
360360 }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
361361 }
362362
......@@ -384,6 +384,7 @@ pub fn roundTripForTag(
384384 if (n == 0) return error.Unexpected;
385385
386386 while (sb.next()) |msg| {
387+ if (msg.header.tag == .Unsupported) return error.Unsupported;
387388 if (msg.header.tag == expected_tag) {
388389 return alloc.dupe(u8, msg.payload) catch return error.Unexpected;
389390 }
......@@ -391,30 +392,6 @@ pub fn roundTripForTag(
391392 return error.Unexpected;
392393 }
393394
394-test "appendSizeMessage emits current and legacy encodings" {
395- const alloc = std.testing.allocator;
396- var list = try std.ArrayList(u8).initCapacity(alloc, 64);
397- defer list.deinit(alloc);
398-
399- const size = Resize{ .rows = 45, .cols = 170, .xpixel = 900, .ypixel = 1800 };
400- try appendSizeMessage(alloc, &list, .Init, size);
401-
402- const h1 = std.mem.bytesToValue(Header, list.items[0..@sizeOf(Header)]);
403- try std.testing.expectEqual(Tag.Init, h1.tag);
404- try std.testing.expectEqual(@as(u32, @sizeOf(Resize)), h1.len);
405- const p1 = list.items[@sizeOf(Header)..][0..@sizeOf(Resize)];
406- try std.testing.expectEqual(size, std.mem.bytesToValue(Resize, p1));
407-
408- const off2 = @sizeOf(Header) + @sizeOf(Resize);
409- const h2 = std.mem.bytesToValue(Header, list.items[off2..][0..@sizeOf(Header)]);
410- try std.testing.expectEqual(Tag.Init, h2.tag);
411- try std.testing.expectEqual(@as(u32, LEGACY_RESIZE_LEN), h2.len);
412- // Legacy payload is the rows+cols prefix of the current encoding.
413- const p2 = list.items[off2 + @sizeOf(Header) ..][0..LEGACY_RESIZE_LEN];
414- try std.testing.expectEqualSlices(u8, p1[0..LEGACY_RESIZE_LEN], p2);
415- try std.testing.expectEqual(off2 + @sizeOf(Header) + LEGACY_RESIZE_LEN, list.items.len);
416-}
417-
418395 test "zeroed Info has no stack garbage in wire bytes" {
419396 var info = std.mem.zeroes(Info);
420397 info.clients_len = 3;
+23, -8
......@@ -47,7 +47,7 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult {
4747
4848 // Send init message with terminal size (buffered)
4949 const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
50- try ipc.appendSizeMessage(gpa, &sock_write_buf, .Init, size);
50+ try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size));
5151
5252 var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4);
5353 defer poll_fds.deinit(gpa);
......@@ -104,7 +104,12 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult {
104104 if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
105105 signal.drainSignalPipe();
106106 const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
107- try ipc.appendSizeMessage(gpa, &sock_write_buf, .Resize, next_size);
107+ try ipc.appendMessage(
108+ gpa,
109+ &sock_write_buf,
110+ .Resize,
111+ std.mem.asBytes(&next_size),
112+ );
108113 }
109114
110115 // Handle stdin -> socket (Input)
......@@ -160,7 +165,12 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult {
160165 // daemon is asking for the client's window size usually in response
161166 // to this client being set as leader.
162167 const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
163- try ipc.appendSizeMessage(gpa, &sock_write_buf, .Resize, next_size);
168+ try ipc.appendMessage(
169+ gpa,
170+ &sock_write_buf,
171+ .Resize,
172+ std.mem.asBytes(&next_size),
173+ );
164174 },
165175 .Switch => {
166176 std.log.info("switch session", .{});
......@@ -492,12 +502,16 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
492502 .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
493503 .Capture => try daemon.handleCapture(gpa, client, &term, msg.payload),
494504 .Run => try daemon.handleRun(gpa, io, client, msg.payload),
495- .Ack, .TaskComplete, .LabelData, .EnvData => {},
505+ .Ack, .TaskComplete, .LabelData, .EnvData, .Unsupported => {},
496506 .Write => try daemon.handleWrite(gpa, client, msg.payload),
497- _ => std.log.warn(
498- "ignoring unknown IPC tag={d}",
499- .{@intFromEnum(msg.header.tag)},
500- ),
507+ _ => {
508+ std.log.warn(
509+ "ignoring unknown IPC tag={d}",
510+ .{@intFromEnum(msg.header.tag)},
511+ );
512+ ipc.appendMessage(gpa, &client.write_buf, .Unsupported, "") catch {};
513+ client.has_pending_output = true;
514+ },
501515 }
502516 }
503517 }
......@@ -1080,6 +1094,7 @@ pub const Daemon = struct {
10801094 // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd
10811095 // tail bytes (daemon stack contents) to clients.
10821096 var info = std.mem.zeroes(ipc.Info);
1097+ info.protocol_version = ipc.PROTOCOL_VERSION;
10831098 info.clients_len = self.clients.items.len - 1;
10841099 info.pid = self.pid;
10851100 info.created_at = self.created_at;
+1, -1
......@@ -13,7 +13,7 @@ sock_dir, name, rows, cols, hold = sys.argv[1], sys.argv[2], int(sys.argv[3]), i
1313 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
1414 s.connect(os.path.join(sock_dir, name))
1515 payload = struct.pack("<HHHH", rows, cols, 0, 0) # ipc.Resize
16-s.sendall(struct.pack("<BIxxx", 7, len(payload)) + payload) # ipc.Header, tag .Init = 7
16+s.sendall(struct.pack("<BBHI", 7, 1, 0, len(payload)) + payload) # ipc.Header (tag=7, ver=1, res=0, len)
1717 time.sleep(hold)
1818 s.close()
1919 PY