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/ | |
| 4 | 4 | ||
| 5 | 5 | ## Staged | |
| 6 | 6 | ||
| 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 | + | ||
| 7 | 12 | ### Added | |
| 8 | 13 | ||
| 9 | 14 | - Completions for [`yash`](https://magicant.github.io/yash/) | |
| 10 | 15 | - History command now accepts --screen (print only active screen) and --scrollback (num of rows to print) | |
| 11 | 16 | ||
| 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 | + | ||
| 12 | 22 | ### Fixed | |
| 13 | 23 | ||
| 14 | 24 | - Stop classifying complete kitty keyboard status replies (`CSI ? flags u`) as user input for leader selection | |
| 15 | 25 | - Prevent infinite loop in `mkdirAll` when directory path contains a dangling symlink | |
| 16 | 26 | - 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` | |
| 17 | 28 | ||
| 18 | 29 | ## v0.8.1 - 2026-09-05 | |
| 19 | 30 |
+35,
-58
| ... | ... | @@ -3,6 +3,8 @@ const cross = @import("cross.zig"); | |
| 3 | 3 | const socket = @import("socket.zig"); | |
| 4 | 4 | const lib_posix = @import("posix.zig"); | |
| 5 | 5 | ||
| 6 | + | pub const PROTOCOL_VERSION: u8 = 1; | |
| 7 | + | ||
| 6 | 8 | pub const Tag = enum(u8) { | |
| 7 | 9 | Input = 0, | |
| 8 | 10 | Output = 1, |
| ... | ... | @@ -29,6 +31,7 @@ pub const Tag = enum(u8) { | |
| 29 | 31 | /// Scoped history: the active screen plus a bounded number of | |
| 30 | 32 | /// preceding scrollback rows. Request payload is `Capture`. | |
| 31 | 33 | Capture = 22, | |
| 34 | + | Unsupported = 255, | |
| 32 | 35 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 33 | 36 | // @enumFromInt, so out-of-range values are representable | |
| 34 | 37 | // rather than UB. Switches must handle `_` (unknown tag). |
| ... | ... | @@ -41,27 +44,35 @@ comptime { | |
| 41 | 44 | ); | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | - | pub const Header = packed struct { | |
| 47 | + | pub const Header = extern struct { | |
| 45 | 48 | tag: Tag, | |
| 49 | + | protocol_version: u8 = PROTOCOL_VERSION, | |
| 50 | + | _reserved: u16 = 0, | |
| 46 | 51 | len: u32, | |
| 47 | 52 | }; | |
| 48 | 53 | ||
| 49 | - | pub const Resize = packed struct { | |
| 54 | + | pub const Resize = extern struct { | |
| 50 | 55 | rows: u16, | |
| 51 | 56 | cols: u16, | |
| 52 | 57 | xpixel: u16 = 0, | |
| 53 | 58 | ypixel: u16 = 0, | |
| 54 | 59 | ||
| 55 | 60 | 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 | + | }; | |
| 57 | 67 | } | |
| 58 | 68 | }; | |
| 59 | 69 | ||
| 60 | 70 | /// Request payload for `Tag.Capture`. `format` is a `util.HistoryFormat` | |
| 61 | 71 | /// and `rows` is how many scrollback rows to include above the active | |
| 62 | 72 | /// screen; zero returns only the screen. | |
| 63 | - | pub const Capture = packed struct { | |
| 73 | + | pub const Capture = extern struct { | |
| 64 | 74 | format: u8, | |
| 75 | + | _reserved: [3]u8 = .{0} ** 3, | |
| 65 | 76 | rows: u32, | |
| 66 | 77 | }; | |
| 67 | 78 |
| ... | ... | @@ -86,6 +97,7 @@ pub fn getTerminalSize(fd: i32) Resize { | |
| 86 | 97 | return .{ .rows = 24, .cols = 120 }; | |
| 87 | 98 | } | |
| 88 | 99 | ||
| 100 | + | pub const MAX_PAYLOAD_LEN: usize = 64 * 1024 * 1024; | |
| 89 | 101 | pub const MAX_CMD_LEN = 256; | |
| 90 | 102 | pub const MAX_CWD_LEN = 256; | |
| 91 | 103 |
| ... | ... | @@ -102,16 +114,10 @@ pub const Info = extern struct { | |
| 102 | 114 | created_at: u64, | |
| 103 | 115 | task_ended_at: u64, | |
| 104 | 116 | task_exit_code: u8, | |
| 117 | + | protocol_version: u8 = PROTOCOL_VERSION, | |
| 118 | + | _reserved: [6]u8 = .{0} ** 6, | |
| 105 | 119 | }; | |
| 106 | 120 | ||
| 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 | - | ||
| 115 | 121 | pub fn send(fd: i32, tag: Tag, data: []const u8) !void { | |
| 116 | 122 | const header = Header{ | |
| 117 | 123 | .tag = tag, |
| ... | ... | @@ -143,23 +149,6 @@ pub fn appendMessage( | |
| 143 | 149 | } | |
| 144 | 150 | } | |
| 145 | 151 | ||
| 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 | - | ||
| 163 | 152 | fn writeAll(fd: i32, data: []const u8) !void { | |
| 164 | 153 | var index: usize = 0; | |
| 165 | 154 | while (index < data.len) { |
| ... | ... | @@ -231,12 +220,21 @@ pub const SocketBuffer = struct { | |
| 231 | 220 | /// valid until the following `next()` (or `deinit`). | |
| 232 | 221 | pub fn next(self: *SocketBuffer) ?SocketMsg { | |
| 233 | 222 | 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; | |
| 236 | 224 | ||
| 237 | 225 | 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 | + | } | |
| 239 | 233 | ||
| 234 | + | const total = @sizeOf(Header) + @as(usize, hdr.len); | |
| 235 | + | if (available.len < total) return null; | |
| 236 | + | ||
| 237 | + | const pay = available[@sizeOf(Header)..total]; | |
| 240 | 238 | self.head += total; | |
| 241 | 239 | return .{ .header = hdr, .payload = pay }; | |
| 242 | 240 | } |
| ... | ... | @@ -261,6 +259,7 @@ const SessionProbeError = error{ | |
| 261 | 259 | ConnectionRefused, | |
| 262 | 260 | Unexpected, | |
| 263 | 261 | InfoSizeMismatch, | |
| 262 | + | Unsupported, | |
| 264 | 263 | }; | |
| 265 | 264 | ||
| 266 | 265 | const SessionProbeResult = struct { |
| ... | ... | @@ -343,8 +342,9 @@ pub fn probeSession( | |
| 343 | 342 | // Need to remove one? → don't. Reserve the integer, stop sending it. | |
| 344 | 343 | test "Info wire size is frozen" { | |
| 345 | 344 | try std.testing.expectEqual(@as(usize, 552), @sizeOf(Info)); | |
| 346 | - | // packed struct{u8,u32} backs to u40 → @sizeOf rounds to 8, not 5. | |
| 347 | 345 | 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)); | |
| 348 | 348 | } | |
| 349 | 349 | ||
| 350 | 350 | test "Tag wire values are frozen" { |
| ... | ... | @@ -356,7 +356,7 @@ test "Tag wire values are frozen" { | |
| 356 | 356 | .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 }, | |
| 357 | 357 | .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 }, | |
| 358 | 358 | .{ 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 }, | |
| 360 | 360 | }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0])); | |
| 361 | 361 | } | |
| 362 | 362 |
| ... | ... | @@ -384,6 +384,7 @@ pub fn roundTripForTag( | |
| 384 | 384 | if (n == 0) return error.Unexpected; | |
| 385 | 385 | ||
| 386 | 386 | while (sb.next()) |msg| { | |
| 387 | + | if (msg.header.tag == .Unsupported) return error.Unsupported; | |
| 387 | 388 | if (msg.header.tag == expected_tag) { | |
| 388 | 389 | return alloc.dupe(u8, msg.payload) catch return error.Unexpected; | |
| 389 | 390 | } |
| ... | ... | @@ -391,30 +392,6 @@ pub fn roundTripForTag( | |
| 391 | 392 | return error.Unexpected; | |
| 392 | 393 | } | |
| 393 | 394 | ||
| 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 | - | ||
| 418 | 395 | test "zeroed Info has no stack garbage in wire bytes" { | |
| 419 | 396 | var info = std.mem.zeroes(Info); | |
| 420 | 397 | info.clients_len = 3; |
+23,
-8
| ... | ... | @@ -47,7 +47,7 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult { | |
| 47 | 47 | ||
| 48 | 48 | // Send init message with terminal size (buffered) | |
| 49 | 49 | 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)); | |
| 51 | 51 | ||
| 52 | 52 | var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4); | |
| 53 | 53 | defer poll_fds.deinit(gpa); |
| ... | ... | @@ -104,7 +104,12 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult { | |
| 104 | 104 | if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) { | |
| 105 | 105 | signal.drainSignalPipe(); | |
| 106 | 106 | 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 | + | ); | |
| 108 | 113 | } | |
| 109 | 114 | ||
| 110 | 115 | // Handle stdin -> socket (Input) |
| ... | ... | @@ -160,7 +165,12 @@ pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult { | |
| 160 | 165 | // daemon is asking for the client's window size usually in response | |
| 161 | 166 | // to this client being set as leader. | |
| 162 | 167 | 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 | + | ); | |
| 164 | 174 | }, | |
| 165 | 175 | .Switch => { | |
| 166 | 176 | std.log.info("switch session", .{}); |
| ... | ... | @@ -492,12 +502,16 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 492 | 502 | .History => try daemon.handleHistory(gpa, client, &term, msg.payload), | |
| 493 | 503 | .Capture => try daemon.handleCapture(gpa, client, &term, msg.payload), | |
| 494 | 504 | .Run => try daemon.handleRun(gpa, io, client, msg.payload), | |
| 495 | - | .Ack, .TaskComplete, .LabelData, .EnvData => {}, | |
| 505 | + | .Ack, .TaskComplete, .LabelData, .EnvData, .Unsupported => {}, | |
| 496 | 506 | .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 | + | }, | |
| 501 | 515 | } | |
| 502 | 516 | } | |
| 503 | 517 | } |
| ... | ... | @@ -1080,6 +1094,7 @@ pub const Daemon = struct { | |
| 1080 | 1094 | // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd | |
| 1081 | 1095 | // tail bytes (daemon stack contents) to clients. | |
| 1082 | 1096 | var info = std.mem.zeroes(ipc.Info); | |
| 1097 | + | info.protocol_version = ipc.PROTOCOL_VERSION; | |
| 1083 | 1098 | info.clients_len = self.clients.items.len - 1; | |
| 1084 | 1099 | info.pid = self.pid; | |
| 1085 | 1100 | 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 | |
| 13 | 13 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | |
| 14 | 14 | s.connect(os.path.join(sock_dir, name)) | |
| 15 | 15 | 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) | |
| 17 | 17 | time.sleep(hold) | |
| 18 | 18 | s.close() | |
| 19 | 19 | PY |