Commit 43e6f0f
Eric Bower
·
2026-04-03 14:22:23 -0400 EDT
parent 66c8e0e
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
| ... | ... | @@ -15,6 +15,7 @@ pub const Tag = enum(u8) { | |
| 15 | 15 | History = 8, | |
| 16 | 16 | Run = 9, | |
| 17 | 17 | Ack = 10, | |
| 18 | + | Switch = 11, | |
| 18 | 19 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 19 | 20 | // @enumFromInt, so out-of-range values (11-255) are representable | |
| 20 | 21 | // rather than UB. Switches must handle `_` (unknown tag). |
+113,
-8
| ... | ... | @@ -635,6 +635,27 @@ const Daemon = struct { | |
| 635 | 635 | } | |
| 636 | 636 | } | |
| 637 | 637 | ||
| 638 | + | pub fn handleSwitch(self: *Daemon, session_name: []const u8) !void { | |
| 639 | + | for (self.clients.items) |client| { | |
| 640 | + | if (self.leader_client_fd == client.socket_fd) { | |
| 641 | + | ipc.appendMessage( | |
| 642 | + | self.alloc, | |
| 643 | + | &client.write_buf, | |
| 644 | + | .Switch, | |
| 645 | + | session_name, | |
| 646 | + | ) catch |err| { | |
| 647 | + | std.log.warn( | |
| 648 | + | "failed to buffer terminal state for client err={s}", | |
| 649 | + | .{@errorName(err)}, | |
| 650 | + | ); | |
| 651 | + | }; | |
| 652 | + | client.has_pending_output = true; | |
| 653 | + | return; | |
| 654 | + | } | |
| 655 | + | } | |
| 656 | + | return error.NoLeaderFound; | |
| 657 | + | } | |
| 658 | + | ||
| 638 | 659 | pub fn handleInit( | |
| 639 | 660 | self: *Daemon, | |
| 640 | 661 | client: *Client, |
| ... | ... | @@ -1197,10 +1218,46 @@ fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !voi | |
| 1197 | 1218 | } | |
| 1198 | 1219 | } | |
| 1199 | 1220 | ||
| 1221 | + | fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void { | |
| 1222 | + | // we want daemon.session_name because that's the session name the user provided during zmx attach | |
| 1223 | + | // instead of the name of the session they are currently inside of. | |
| 1224 | + | const next_session = daemon.session_name; | |
| 1225 | + | ||
| 1226 | + | const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) { | |
| 1227 | + | error.NameTooLong => return socket.printSessionNameTooLong(current_sesh, daemon.cfg.socket_dir), | |
| 1228 | + | error.OutOfMemory => return err, | |
| 1229 | + | }; | |
| 1230 | + | defer daemon.alloc.free(socket_path); | |
| 1231 | + | ||
| 1232 | + | var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{}); | |
| 1233 | + | defer dir.close(); | |
| 1234 | + | ||
| 1235 | + | const exists = try socket.sessionExists(dir, current_sesh); | |
| 1236 | + | if (!exists) { | |
| 1237 | + | var buf: [4096]u8 = undefined; | |
| 1238 | + | var w = std.fs.File.stderr().writer(&buf); | |
| 1239 | + | w.interface.print("error: session \"{s}\" does not exist\n", .{current_sesh}) catch {}; | |
| 1240 | + | w.interface.flush() catch {}; | |
| 1241 | + | return error.SessionNotFound; | |
| 1242 | + | } | |
| 1243 | + | const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| { | |
| 1244 | + | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 1245 | + | if (err == error.ConnectionRefused) socket.cleanupStaleSocket(dir, current_sesh); | |
| 1246 | + | return; | |
| 1247 | + | }; | |
| 1248 | + | defer posix.close(result.fd); | |
| 1249 | + | ||
| 1250 | + | ipc.send(result.fd, .Switch, next_session) catch |err| switch (err) { | |
| 1251 | + | error.BrokenPipe, error.ConnectionResetByPeer => return, | |
| 1252 | + | else => return err, | |
| 1253 | + | }; | |
| 1254 | + | } | |
| 1255 | + | ||
| 1200 | 1256 | fn attach(daemon: *Daemon) !void { | |
| 1201 | 1257 | const sesh = socket.getSeshNameFromEnv(); | |
| 1202 | 1258 | if (sesh.len > 0) { | |
| 1203 | - | return error.CannotAttachToSessionInSession; | |
| 1259 | + | return switchSesh(daemon, sesh); | |
| 1260 | + | // return error.CannotAttachToSessionInSession; | |
| 1204 | 1261 | } | |
| 1205 | 1262 | ||
| 1206 | 1263 | const result = try daemon.ensureSession(); |
| ... | ... | @@ -1264,7 +1321,42 @@ fn attach(daemon: *Daemon) !void { | |
| 1264 | 1321 | const clear_seq = "\x1b[2J\x1b[H"; | |
| 1265 | 1322 | _ = try posix.write(posix.STDOUT_FILENO, clear_seq); | |
| 1266 | 1323 | ||
| 1267 | - | try clientLoop(client_sock); | |
| 1324 | + | const looper = try clientLoop(client_sock); | |
| 1325 | + | switch (looper.kind) { | |
| 1326 | + | .detach => return, | |
| 1327 | + | .switch_session => { | |
| 1328 | + | if (looper.session_name) |session_name| { | |
| 1329 | + | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1330 | + | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 1331 | + | const target_path = socket.getSocketPath( | |
| 1332 | + | daemon.alloc, | |
| 1333 | + | daemon.cfg.socket_dir, | |
| 1334 | + | session_name, | |
| 1335 | + | ) catch |err| switch (err) { | |
| 1336 | + | error.NameTooLong => return socket.printSessionNameTooLong( | |
| 1337 | + | session_name, | |
| 1338 | + | daemon.cfg.socket_dir, | |
| 1339 | + | ), | |
| 1340 | + | error.OutOfMemory => return err, | |
| 1341 | + | }; | |
| 1342 | + | ||
| 1343 | + | const clients = try std.ArrayList(*Client).initCapacity(daemon.alloc, 10); | |
| 1344 | + | var target_daemon = Daemon{ | |
| 1345 | + | .running = true, | |
| 1346 | + | .cfg = daemon.cfg, | |
| 1347 | + | .alloc = daemon.alloc, | |
| 1348 | + | .clients = clients, | |
| 1349 | + | .session_name = session_name, | |
| 1350 | + | .socket_path = target_path, | |
| 1351 | + | .pid = undefined, | |
| 1352 | + | .cwd = cwd, | |
| 1353 | + | .created_at = @intCast(std.time.timestamp()), | |
| 1354 | + | .leader_client_fd = null, | |
| 1355 | + | }; | |
| 1356 | + | return attach(&target_daemon); | |
| 1357 | + | } | |
| 1358 | + | }, | |
| 1359 | + | } | |
| 1268 | 1360 | } | |
| 1269 | 1361 | ||
| 1270 | 1362 | fn run(daemon: *Daemon, command_args: [][]const u8) !void { |
| ... | ... | @@ -1398,9 +1490,17 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1398 | 1490 | return error.NoAckReceived; | |
| 1399 | 1491 | } | |
| 1400 | 1492 | ||
| 1493 | + | const ClientResult = struct { | |
| 1494 | + | kind: enum { | |
| 1495 | + | detach, | |
| 1496 | + | switch_session, | |
| 1497 | + | }, | |
| 1498 | + | session_name: ?[]const u8, | |
| 1499 | + | }; | |
| 1500 | + | ||
| 1401 | 1501 | /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking | |
| 1402 | 1502 | /// mechanism. It will send stdin to the daemon and receive stdout from the daemon. | |
| 1403 | - | fn clientLoop(client_sock_fd: i32) !void { | |
| 1503 | + | fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 1404 | 1504 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 1405 | 1505 | const alloc = std.heap.c_allocator; | |
| 1406 | 1506 | defer posix.close(client_sock_fd); |
| ... | ... | @@ -1496,7 +1596,7 @@ fn clientLoop(client_sock_fd: i32) !void { | |
| 1496 | 1596 | } | |
| 1497 | 1597 | } else { | |
| 1498 | 1598 | // EOF on stdin | |
| 1499 | - | return; | |
| 1599 | + | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 1500 | 1600 | } | |
| 1501 | 1601 | } | |
| 1502 | 1602 | } |
| ... | ... | @@ -1506,13 +1606,14 @@ fn clientLoop(client_sock_fd: i32) !void { | |
| 1506 | 1606 | const n = read_buf.read(client_sock_fd) catch |err| { | |
| 1507 | 1607 | if (err == error.WouldBlock) continue; | |
| 1508 | 1608 | if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) { | |
| 1509 | - | return; | |
| 1609 | + | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 1510 | 1610 | } | |
| 1511 | 1611 | std.log.err("daemon read err={s}", .{@errorName(err)}); | |
| 1512 | 1612 | return err; | |
| 1513 | 1613 | }; | |
| 1514 | 1614 | if (n == 0) { | |
| 1515 | - | return; // Server closed connection | |
| 1615 | + | // Server closed connection | |
| 1616 | + | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 1516 | 1617 | } | |
| 1517 | 1618 | ||
| 1518 | 1619 | while (read_buf.next()) |msg| { |
| ... | ... | @@ -1533,6 +1634,9 @@ fn clientLoop(client_sock_fd: i32) !void { | |
| 1533 | 1634 | std.mem.asBytes(&next_size), | |
| 1534 | 1635 | ); | |
| 1535 | 1636 | }, | |
| 1637 | + | .Switch => { | |
| 1638 | + | return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) }; | |
| 1639 | + | }, | |
| 1536 | 1640 | else => {}, | |
| 1537 | 1641 | } | |
| 1538 | 1642 | } |
| ... | ... | @@ -1544,7 +1648,7 @@ fn clientLoop(client_sock_fd: i32) !void { | |
| 1544 | 1648 | const n = posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: { | |
| 1545 | 1649 | if (err == error.WouldBlock) break :blk 0; | |
| 1546 | 1650 | if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) { | |
| 1547 | - | return; | |
| 1651 | + | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 1548 | 1652 | } | |
| 1549 | 1653 | return err; | |
| 1550 | 1654 | }; |
| ... | ... | @@ -1565,7 +1669,7 @@ fn clientLoop(client_sock_fd: i32) !void { | |
| 1565 | 1669 | } | |
| 1566 | 1670 | ||
| 1567 | 1671 | if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { | |
| 1568 | - | return; | |
| 1672 | + | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 1569 | 1673 | } | |
| 1570 | 1674 | } | |
| 1571 | 1675 | } |
| ... | ... | @@ -1766,6 +1870,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1766 | 1870 | switch (msg.header.tag) { | |
| 1767 | 1871 | .Input => try daemon.handleInput(client, msg.payload), | |
| 1768 | 1872 | .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload), | |
| 1873 | + | .Switch => try daemon.handleSwitch(msg.payload), | |
| 1769 | 1874 | .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload), | |
| 1770 | 1875 | .Detach => { | |
| 1771 | 1876 | daemon.handleDetach(client, i); |