repos / zmx

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

commit
43e6f0f
parent
66c8e0e
author
Eric Bower
date
2026-04-03 14:22:23 -0400 EDT
feat(attach): support switching sessions

Previously we did not allow users to switch to a session from within a session
via `zmx attach`.

Now users are able to run `zmx attach` from within a session and it'll properly
detach and then reattach to the new session.

References: https://github.com/neurosnap/zmx/issues/91
2 files changed,  +114, -8
M src/ipc.zig
+1, -0
1@@ -15,6 +15,7 @@ pub const Tag = enum(u8) {
2     History = 8,
3     Run = 9,
4     Ack = 10,
5+    Switch = 11,
6     // Non-exhaustive: this enum comes off the wire via bytesToValue and
7     // @enumFromInt, so out-of-range values (11-255) are representable
8     // rather than UB. Switches must handle `_` (unknown tag).
M src/main.zig
+113, -8
  1@@ -635,6 +635,27 @@ const Daemon = struct {
  2         }
  3     }
  4 
  5+    pub fn handleSwitch(self: *Daemon, session_name: []const u8) !void {
  6+        for (self.clients.items) |client| {
  7+            if (self.leader_client_fd == client.socket_fd) {
  8+                ipc.appendMessage(
  9+                    self.alloc,
 10+                    &client.write_buf,
 11+                    .Switch,
 12+                    session_name,
 13+                ) catch |err| {
 14+                    std.log.warn(
 15+                        "failed to buffer terminal state for client err={s}",
 16+                        .{@errorName(err)},
 17+                    );
 18+                };
 19+                client.has_pending_output = true;
 20+                return;
 21+            }
 22+        }
 23+        return error.NoLeaderFound;
 24+    }
 25+
 26     pub fn handleInit(
 27         self: *Daemon,
 28         client: *Client,
 29@@ -1197,10 +1218,46 @@ fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !voi
 30     }
 31 }
 32 
 33+fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
 34+    // we want daemon.session_name because that's the session name the user provided during zmx attach
 35+    // instead of the name of the session they are currently inside of.
 36+    const next_session = daemon.session_name;
 37+
 38+    const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
 39+        error.NameTooLong => return socket.printSessionNameTooLong(current_sesh, daemon.cfg.socket_dir),
 40+        error.OutOfMemory => return err,
 41+    };
 42+    defer daemon.alloc.free(socket_path);
 43+
 44+    var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
 45+    defer dir.close();
 46+
 47+    const exists = try socket.sessionExists(dir, current_sesh);
 48+    if (!exists) {
 49+        var buf: [4096]u8 = undefined;
 50+        var w = std.fs.File.stderr().writer(&buf);
 51+        w.interface.print("error: session \"{s}\" does not exist\n", .{current_sesh}) catch {};
 52+        w.interface.flush() catch {};
 53+        return error.SessionNotFound;
 54+    }
 55+    const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
 56+        std.log.err("session unresponsive: {s}", .{@errorName(err)});
 57+        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(dir, current_sesh);
 58+        return;
 59+    };
 60+    defer posix.close(result.fd);
 61+
 62+    ipc.send(result.fd, .Switch, next_session) catch |err| switch (err) {
 63+        error.BrokenPipe, error.ConnectionResetByPeer => return,
 64+        else => return err,
 65+    };
 66+}
 67+
 68 fn attach(daemon: *Daemon) !void {
 69     const sesh = socket.getSeshNameFromEnv();
 70     if (sesh.len > 0) {
 71-        return error.CannotAttachToSessionInSession;
 72+        return switchSesh(daemon, sesh);
 73+        // return error.CannotAttachToSessionInSession;
 74     }
 75 
 76     const result = try daemon.ensureSession();
 77@@ -1264,7 +1321,42 @@ fn attach(daemon: *Daemon) !void {
 78     const clear_seq = "\x1b[2J\x1b[H";
 79     _ = try posix.write(posix.STDOUT_FILENO, clear_seq);
 80 
 81-    try clientLoop(client_sock);
 82+    const looper = try clientLoop(client_sock);
 83+    switch (looper.kind) {
 84+        .detach => return,
 85+        .switch_session => {
 86+            if (looper.session_name) |session_name| {
 87+                var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 88+                const cwd = std.posix.getcwd(&cwd_buf) catch "";
 89+                const target_path = socket.getSocketPath(
 90+                    daemon.alloc,
 91+                    daemon.cfg.socket_dir,
 92+                    session_name,
 93+                ) catch |err| switch (err) {
 94+                    error.NameTooLong => return socket.printSessionNameTooLong(
 95+                        session_name,
 96+                        daemon.cfg.socket_dir,
 97+                    ),
 98+                    error.OutOfMemory => return err,
 99+                };
100+
101+                const clients = try std.ArrayList(*Client).initCapacity(daemon.alloc, 10);
102+                var target_daemon = Daemon{
103+                    .running = true,
104+                    .cfg = daemon.cfg,
105+                    .alloc = daemon.alloc,
106+                    .clients = clients,
107+                    .session_name = session_name,
108+                    .socket_path = target_path,
109+                    .pid = undefined,
110+                    .cwd = cwd,
111+                    .created_at = @intCast(std.time.timestamp()),
112+                    .leader_client_fd = null,
113+                };
114+                return attach(&target_daemon);
115+            }
116+        },
117+    }
118 }
119 
120 fn run(daemon: *Daemon, command_args: [][]const u8) !void {
121@@ -1398,9 +1490,17 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
122     return error.NoAckReceived;
123 }
124 
125+const ClientResult = struct {
126+    kind: enum {
127+        detach,
128+        switch_session,
129+    },
130+    session_name: ?[]const u8,
131+};
132+
133 /// clientLoop sends ipc commands to its corresponding daemon.  It uses poll() as its non-blocking
134 /// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
135-fn clientLoop(client_sock_fd: i32) !void {
136+fn clientLoop(client_sock_fd: i32) !ClientResult {
137     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
138     const alloc = std.heap.c_allocator;
139     defer posix.close(client_sock_fd);
140@@ -1496,7 +1596,7 @@ fn clientLoop(client_sock_fd: i32) !void {
141                     }
142                 } else {
143                     // EOF on stdin
144-                    return;
145+                    return ClientResult{ .kind = .detach, .session_name = null };
146                 }
147             }
148         }
149@@ -1506,13 +1606,14 @@ fn clientLoop(client_sock_fd: i32) !void {
150             const n = read_buf.read(client_sock_fd) catch |err| {
151                 if (err == error.WouldBlock) continue;
152                 if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
153-                    return;
154+                    return ClientResult{ .kind = .detach, .session_name = null };
155                 }
156                 std.log.err("daemon read err={s}", .{@errorName(err)});
157                 return err;
158             };
159             if (n == 0) {
160-                return; // Server closed connection
161+                // Server closed connection
162+                return ClientResult{ .kind = .detach, .session_name = null };
163             }
164 
165             while (read_buf.next()) |msg| {
166@@ -1533,6 +1634,9 @@ fn clientLoop(client_sock_fd: i32) !void {
167                             std.mem.asBytes(&next_size),
168                         );
169                     },
170+                    .Switch => {
171+                        return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) };
172+                    },
173                     else => {},
174                 }
175             }
176@@ -1544,7 +1648,7 @@ fn clientLoop(client_sock_fd: i32) !void {
177                 const n = posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
178                     if (err == error.WouldBlock) break :blk 0;
179                     if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
180-                        return;
181+                        return ClientResult{ .kind = .detach, .session_name = null };
182                     }
183                     return err;
184                 };
185@@ -1565,7 +1669,7 @@ fn clientLoop(client_sock_fd: i32) !void {
186         }
187 
188         if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
189-            return;
190+            return ClientResult{ .kind = .detach, .session_name = null };
191         }
192     }
193 }
194@@ -1766,6 +1870,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
195                     switch (msg.header.tag) {
196                         .Input => try daemon.handleInput(client, msg.payload),
197                         .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
198+                        .Switch => try daemon.handleSwitch(msg.payload),
199                         .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload),
200                         .Detach => {
201                             daemon.handleDetach(client, i);