repos / zmx

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

commit
085fe3e
parent
e889a18
author
Ian Tay
date
2026-04-10 12:06:49 -0400 EDT
refactor(ipc): freeze Tag wire values; switch via connectSession
2 files changed,  +30, -7
M src/ipc.zig
+27, -4
 1@@ -24,6 +24,12 @@ pub const Tag = enum(u8) {
 2     _,
 3 };
 4 
 5+comptime {
 6+    if (@typeInfo(Tag).@"enum".is_exhaustive) @compileError(
 7+        "ipc.Tag must stay non-exhaustive — old daemons rely on `_` to ignore unknown tags",
 8+    );
 9+}
10+
11 pub const Header = packed struct {
12     tag: Tag,
13     len: u32,
14@@ -184,8 +190,8 @@ const ConnectError = error{
15     Unexpected,
16 };
17 
18-/// Unlike `probeSession`, does not round-trip `Info` — kill/detach/history/run
19-/// stay usable against version-skewed daemons.
20+/// Connect-only liveness check. Callers that don't read `Info` should use
21+/// this (not `probeSession`) so they survive `Info` shape changes.
22 pub fn connectSession(socket_path: []const u8) ConnectError!i32 {
23     return socket.sessionConnect(socket_path) catch |err| switch (err) {
24         error.ConnectionRefused => return error.ConnectionRefused,
25@@ -239,14 +245,31 @@ pub fn probeSession(
26     return error.Unexpected;
27 }
28 
29+// ╔════════════════════════════════════════════════════════════════════╗
30+// ║ WIRE PROTOCOL FREEZE — read before "fixing" any test below.        ║
31+// ║                                                                    ║
32+// ║ Changing these constants does not fix the test; it breaks every    ║
33+// ║ running daemon for every user until they `pkill -f zmx`.           ║
34+// ║                                                                    ║
35+// ║ Need a new field?  → add a new `Tag` value (next free integer).    ║
36+// ║ Need to remove one? → don't. Reserve the integer, stop sending it. ║
37+// ╚════════════════════════════════════════════════════════════════════╝
38 test "Info wire size is frozen" {
39-    // Bumping this means version-skewed `zmx list` breaks. See doc comment
40-    // on `Info` — add a new `Tag` instead of growing this struct.
41     try std.testing.expectEqual(@as(usize, 552), @sizeOf(Info));
42     // packed struct{u8,u32} backs to u40 → @sizeOf rounds to 8, not 5.
43     try std.testing.expectEqual(@as(usize, 8), @sizeOf(Header));
44 }
45 
46+test "Tag wire values are frozen" {
47+    inline for (.{
48+        .{ Tag.Input, 0 },  .{ Tag.Output, 1 },        .{ Tag.Resize, 2 },
49+        .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 },     .{ Tag.Kill, 5 },
50+        .{ Tag.Info, 6 },   .{ Tag.Init, 7 },          .{ Tag.History, 8 },
51+        .{ Tag.Run, 9 },    .{ Tag.Ack, 10 },          .{ Tag.Switch, 11 },
52+        .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 },
53+    }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
54+}
55+
56 test "zeroed Info has no stack garbage in wire bytes" {
57     var info = std.mem.zeroes(Info);
58     info.clients_len = 3;
M src/main.zig
+3, -3
 1@@ -1766,14 +1766,14 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
 2         w.interface.flush() catch {};
 3         return error.SessionNotFound;
 4     }
 5-    const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
 6+    const fd = ipc.connectSession(socket_path) catch |err| {
 7         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 8         if (err == error.ConnectionRefused) socket.cleanupStaleSocket(dir, current_sesh);
 9         return;
10     };
11-    defer posix.close(result.fd);
12+    defer posix.close(fd);
13 
14-    ipc.send(result.fd, .Switch, next_session) catch |err| switch (err) {
15+    ipc.send(fd, .Switch, next_session) catch |err| switch (err) {
16         error.BrokenPipe, error.ConnectionResetByPeer => return,
17         else => return err,
18     };