repos / zmx

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

commit
3b0e4e8
parent
89e2728
author
Eric Bower
date
2026-03-22 22:12:51 -0400 EDT
docs: doc strings
1 files changed,  +47, -15
M src/main.zig
+47, -15
  1@@ -187,6 +187,9 @@ pub fn main() !void {
  2     }
  3 }
  4 
  5+/// Client represents each terminal that has connected to a session.
  6+///
  7+/// Multiple Clients can connect to a single session.
  8 const Client = struct {
  9     alloc: std.mem.Allocator,
 10     socket_fd: i32,
 11@@ -201,23 +204,16 @@ const Client = struct {
 12     }
 13 };
 14 
 15+/// Cfg is zmx's configuration container.
 16+///
 17+/// The purpose of this container is to hold anything that can be modified by the user.
 18 const Cfg = struct {
 19     socket_dir: []const u8,
 20     log_dir: []const u8,
 21     max_scrollback: usize = 10_000_000,
 22 
 23     pub fn init(alloc: std.mem.Allocator) !Cfg {
 24-        const tmpdir = std.mem.trimRight(u8, posix.getenv("TMPDIR") orelse "/tmp", "/");
 25-        const uid = posix.getuid();
 26-
 27-        const socket_dir: []const u8 = if (posix.getenv("ZMX_DIR")) |zmxdir|
 28-            try alloc.dupe(u8, zmxdir)
 29-        else if (posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
 30-            try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
 31-        else
 32-            try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 33-        errdefer alloc.free(socket_dir);
 34-
 35+        const socket_dir = try socketDir(alloc);
 36         const log_dir = try std.fmt.allocPrint(alloc, "{s}/logs", .{socket_dir});
 37         errdefer alloc.free(log_dir);
 38 
 39@@ -231,6 +227,21 @@ const Cfg = struct {
 40         return cfg;
 41     }
 42 
 43+    fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
 44+        const tmpdir = std.mem.trimRight(u8, posix.getenv("TMPDIR") orelse "/tmp", "/");
 45+        const uid = posix.getuid();
 46+
 47+        const socket_dir: []const u8 = if (posix.getenv("ZMX_DIR")) |zmxdir|
 48+            try alloc.dupe(u8, zmxdir)
 49+        else if (posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
 50+            try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
 51+        else
 52+            try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 53+        errdefer alloc.free(socket_dir);
 54+
 55+        return socket_dir;
 56+    }
 57+
 58     pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
 59         if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
 60         if (self.log_dir.len > 0) alloc.free(self.log_dir);
 61@@ -254,6 +265,14 @@ const EnsureSessionResult = struct {
 62     is_daemon: bool,
 63 };
 64 
 65+/// Daemon is responsible for managing a zmx session.
 66+///
 67+/// It holds all the state for a running session.  Instead of a single daemon for all sessions, we
 68+/// create a daemon for every session.  This has some benefits. The ipc communication between
 69+/// session clients and the daemon doesn't need to be tagged with the session name.  If a daemon
 70+/// crashes for one session won't crash all the other sessions.
 71+///
 72+/// Conceptually it's also much simpler to reason about.
 73 const Daemon = struct {
 74     cfg: *Cfg,
 75     alloc: std.mem.Allocator,
 76@@ -343,6 +362,7 @@ const Daemon = struct {
 77         std.posix.exit(1);
 78     }
 79 
 80+    /// spawnPty runs forkpty() and executes the shell or shell command the user provides.
 81     fn spawnPty(self: *Daemon) !c_int {
 82         const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
 83         var ws: cross.c.struct_winsize = .{
 84@@ -379,6 +399,8 @@ const Daemon = struct {
 85         return master_fd;
 86     }
 87 
 88+    /// ensureSession "upserts" a session by checking if the unix socket exists already.
 89+    /// If not it creates one and spawns the daemon.
 90     fn ensureSession(self: *Daemon) !EnsureSessionResult {
 91         var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{});
 92         defer dir.close();
 93@@ -411,8 +433,10 @@ const Daemon = struct {
 94             std.log.info("creating session={s}", .{self.session_name});
 95             const server_sock_fd = try socket.createSocket(self.socket_path);
 96 
 97+            // creates the daemon
 98             const pid = try posix.fork();
 99             if (pid == 0) { // child (daemon)
100+                // becomes the session leader and detaches process from its controlling terminal
101                 _ = try posix.setsid();
102 
103                 log_system.deinit();
104@@ -695,10 +719,10 @@ fn help() !void {
105         \\  [r]un <name> [command...]      Send command without attaching, creating session if needed
106         \\  [d]etach                       Detach all clients from current session (ctrl+\ for current client)
107         \\  [l]ist [--short]               List active sessions
108-        \\  [c]ompletions <shell>          Completion scripts for shell integration (bash, zsh, or fish)
109         \\  [k]ill <name>                  Kill a session and all attached clients
110         \\  [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
111         \\  [w]ait <name>...               Wait for session tasks to complete
112+        \\  [c]ompletions <shell>          Completion scripts for shell integration (bash, zsh, or fish)
113         \\  [v]ersion                      Show version information
114         \\  [h]elp                         Show this help message
115         \\
116@@ -707,7 +731,7 @@ fn help() !void {
117         \\  - ZMX_DIR              Controls which folder is used to store unix socket files (prio: 1)
118         \\  - XDG_RUNTIME_DIR      Controls which folder is used to store unix socket files (prio: 2)
119         \\  - TMPDIR               Controls which folder is used to store unix socket files (prio: 3)
120-        \\  - ZMX_SESSION          This variable is injected into every zmx session automatically
121+        \\  - ZMX_SESSION          The session name we inject into every zmx session automatically
122         \\  - ZMX_SESSION_PREFIX   Adds this value to the start of every session name for all commands
123         \\
124     ;
125@@ -1001,7 +1025,7 @@ fn attach(daemon: *Daemon) !void {
126 
127     const client_sock = try socket.sessionConnect(daemon.socket_path);
128     std.log.info("attached session={s}", .{daemon.session_name});
129-    //  this is typically used with tcsetattr() to modify terminal settings.
130+    //  This is typically used with tcsetattr() to modify terminal settings.
131     //      - you first get the current settings with tcgetattr()
132     //      - modify the desired attributes in the termios structure
133     //      - then apply the changes with tcsetattr().
134@@ -1079,6 +1103,10 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
135 
136     const shell = util.detectShell();
137     const shell_basename = std.fs.path.basename(shell);
138+    // We append a task marker so we can:
139+    //   - know when the command finishes
140+    //   - capture its exit status
141+    // This information is retrived when running `zmx list`
142     const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
143         "; echo ZMX_TASK_COMPLETED:$status"
144     else
145@@ -1185,6 +1213,8 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
146     return error.NoAckReceived;
147 }
148 
149+/// clientLoop sends ipc commands to its corresponding daemon.  It uses poll() as its non-blocking
150+/// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
151 fn clientLoop(client_sock_fd: i32) !void {
152     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
153     const alloc = std.heap.c_allocator;
154@@ -1343,6 +1373,8 @@ fn clientLoop(client_sock_fd: i32) !void {
155     }
156 }
157 
158+/// dameonLoop is what the daemon runs to send and receive ipc commands from its corresponding
159+/// clients.  It uses poll() as its non-blocking mechanism.
160 fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
161     std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
162     setupSigtermHandler();
163@@ -1554,7 +1586,7 @@ fn handleSigterm(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c)
164     sigterm_received.store(true, .release);
165 }
166 
167-// No SA_RESTART on these: we WANT the signal to interrupt poll() so the
168+// No SA_RESTART: we want the signal to interrupt poll() so the
169 // loop can check the flag. On BSD/macOS, SA_RESTART makes poll restartable,
170 // which would leave an idle daemon deaf to SIGTERM until other I/O wakes it.
171 fn setupSigwinchHandler() void {