- 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
+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).
+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);