repos / zmx

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

commit
3a901e0
parent
e719274
author
Eric Bower
date
2026-04-14 15:04:15 -0400 EDT
refactor: require "*" suffix for wildcard matches

This applies to: wait, kill, and tail

Examples:

- zmx wait "dev*"
- zmx kill "dev*"
- zmx tail "dev*"
- zmx kill "*"
1 files changed,  +94, -52
M src/main.zig
+94, -52
  1@@ -36,6 +36,25 @@ var sigterm_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false
  2 // https://github.com/ziglang/zig/blob/738d2be9d6b6ef3ff3559130c05159ef53336224/lib/std/posix.zig#L3505
  3 const O_NONBLOCK: usize = 1 << @bitOffsetOf(posix.O, "NONBLOCK");
  4 
  5+const SessionMatch = struct {
  6+    name: []const u8,
  7+    is_prefix: bool,
  8+
  9+    fn matches(self: SessionMatch, session_name: []const u8) bool {
 10+        if (self.is_prefix) return std.mem.startsWith(u8, session_name, self.name);
 11+        return std.mem.eql(u8, session_name, self.name);
 12+    }
 13+};
 14+
 15+fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
 16+    if (raw.len > 0 and raw[raw.len - 1] == '*') {
 17+        const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]);
 18+        return .{ .name = name, .is_prefix = true };
 19+    }
 20+    const name = try socket.getSeshName(alloc, raw);
 21+    return .{ .name = name, .is_prefix = false };
 22+}
 23+
 24 pub fn main() !void {
 25     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 26     const alloc = std.heap.c_allocator;
 27@@ -185,12 +204,12 @@ pub fn main() !void {
 28         var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
 29         const stderr = &stderr_writer.interface;
 30 
 31-        var args_raw: std.ArrayList([]const u8) = .empty;
 32+        var matchers: std.ArrayList(SessionMatch) = .empty;
 33         defer {
 34-            for (args_raw.items) |sesh| {
 35-                alloc.free(sesh);
 36+            for (matchers.items) |m| {
 37+                alloc.free(m.name);
 38             }
 39-            args_raw.deinit(alloc);
 40+            matchers.deinit(alloc);
 41         }
 42         var force = false;
 43         while (args.next()) |session_name| {
 44@@ -198,16 +217,11 @@ pub fn main() !void {
 45                 force = true;
 46                 continue;
 47             }
 48-            const sesh = try socket.getSeshName(alloc, session_name);
 49-            try args_raw.append(alloc, sesh);
 50-        }
 51-        // if no args are provided we assume they want to kill all sessions matching the prefix.
 52-        if (args_raw.items.len == 0) {
 53-            const prefix = socket.getSeshPrefix();
 54-            if (prefix.len == 0) {
 55-                return error.SessionNameRequired;
 56-            }
 57-            try args_raw.append(alloc, try alloc.dupe(u8, prefix));
 58+            const m = try parseSessionArg(alloc, session_name);
 59+            try matchers.append(alloc, m);
 60+        }
 61+        if (matchers.items.len == 0) {
 62+            return error.SessionNameRequired;
 63         }
 64         var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
 65         defer {
 66@@ -218,8 +232,8 @@ pub fn main() !void {
 67         }
 68 
 69         for (sessions.items) |session| {
 70-            for (args_raw.items) |prefix| {
 71-                if (!std.mem.startsWith(u8, session.name, prefix)) {
 72+            for (matchers.items) |m| {
 73+                if (!m.matches(session.name)) {
 74                     continue;
 75                 }
 76 
 77@@ -234,50 +248,79 @@ pub fn main() !void {
 78             }
 79         }
 80     } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 81-        var args_raw: std.ArrayList([]const u8) = .empty;
 82+        var matchers: std.ArrayList(SessionMatch) = .empty;
 83         defer {
 84-            for (args_raw.items) |sesh| {
 85-                alloc.free(sesh);
 86+            for (matchers.items) |m| {
 87+                alloc.free(m.name);
 88             }
 89-            args_raw.deinit(alloc);
 90+            matchers.deinit(alloc);
 91         }
 92         while (args.next()) |session_name| {
 93-            const sesh = try socket.getSeshName(alloc, session_name);
 94-            try args_raw.append(alloc, sesh);
 95-        }
 96-        // if no args are provided we assume they want to wait for all sessions matching the
 97-        // prefix.
 98-        if (args_raw.items.len == 0) {
 99-            const prefix = socket.getSeshPrefix();
100-            if (prefix.len == 0) {
101-                return error.SessionNameRequired;
102-            }
103-            try args_raw.append(alloc, prefix);
104+            const m = try parseSessionArg(alloc, session_name);
105+            try matchers.append(alloc, m);
106         }
107-        return wait(&cfg, args_raw);
108+        if (matchers.items.len == 0) {
109+            return error.SessionNameRequired;
110+        }
111+        return wait(&cfg, matchers);
112     } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
113-        var session_names: std.ArrayList([]const u8) = .empty;
114+        var matchers: std.ArrayList(SessionMatch) = .empty;
115         defer {
116-            for (session_names.items) |sesh| {
117-                alloc.free(sesh);
118+            for (matchers.items) |m| {
119+                alloc.free(m.name);
120             }
121-            session_names.deinit(alloc);
122+            matchers.deinit(alloc);
123         }
124         while (args.next()) |session_name| {
125-            const sesh = try socket.getSeshName(alloc, session_name);
126-            try session_names.append(alloc, sesh);
127-        }
128-        // if no args are provided we assume they want to wait for all sessions matching the
129-        // prefix.
130-        if (session_names.items.len == 0) {
131-            const prefix = socket.getSeshPrefix();
132-            if (prefix.len == 0) {
133-                return error.SessionNameRequired;
134+            const m = try parseSessionArg(alloc, session_name);
135+            try matchers.append(alloc, m);
136+        }
137+        if (matchers.items.len == 0) {
138+            return error.SessionNameRequired;
139+        }
140+
141+        // Resolve matchers against session list to get actual session names.
142+        var resolved_names: std.ArrayList([]const u8) = .empty;
143+        defer {
144+            for (resolved_names.items) |name| {
145+                alloc.free(name);
146             }
147-            try session_names.append(alloc, prefix);
148+            resolved_names.deinit(alloc);
149         }
150 
151-        var client_socket_fds = try std.ArrayList(i32).initCapacity(alloc, session_names.items.len);
152+        var any_prefix = false;
153+        for (matchers.items) |m| {
154+            if (m.is_prefix) {
155+                any_prefix = true;
156+                break;
157+            }
158+        }
159+
160+        if (any_prefix) {
161+            var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
162+            defer {
163+                for (sessions.items) |session| {
164+                    session.deinit(alloc);
165+                }
166+                sessions.deinit(alloc);
167+            }
168+            for (sessions.items) |session| {
169+                for (matchers.items) |m| {
170+                    if (m.matches(session.name)) {
171+                        try resolved_names.append(alloc, try alloc.dupe(u8, session.name));
172+                        break;
173+                    }
174+                }
175+            }
176+        }
177+        // Add exact-match names directly.
178+        for (matchers.items) |m| {
179+            if (!m.is_prefix) {
180+                try resolved_names.append(alloc, try alloc.dupe(u8, m.name));
181+            }
182+        }
183+
184+        var client_socket_fds = try std.ArrayList(i32).initCapacity(alloc, resolved_names.items.len);
185         defer {
186             for (client_socket_fds.items) |client_fd| {
187                 posix.close(client_fd);
188@@ -285,7 +328,7 @@ pub fn main() !void {
189             client_socket_fds.deinit(alloc);
190         }
191 
192-        for (session_names.items) |session_name| {
193+        for (resolved_names.items) |session_name| {
194             const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
195                 error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
196                 error.OutOfMemory => return err,
197@@ -449,7 +492,6 @@ test "Cfg.init uses custom modes from env vars" {
198     try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
199 }
200 
201-
202 /// Daemon is responsible for managing a zmx session.
203 ///
204 /// It holds all the state for a running session.  Instead of a single daemon for all sessions, we
205@@ -1295,7 +1337,7 @@ fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool)
206     }
207 }
208 
209-fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
210+fn wait(cfg: *Cfg, matchers: std.ArrayList(SessionMatch)) !void {
211     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
212     defer _ = gpa.deinit();
213     const alloc = gpa.allocator();
214@@ -1322,8 +1364,8 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
215 
216         for (sessions.items) |session| {
217             var found = false;
218-            for (session_names.items) |prefix| {
219-                if (std.mem.startsWith(u8, session.name, prefix)) {
220+            for (matchers.items) |m| {
221+                if (m.matches(session.name)) {
222                     found = true;
223                     break;
224                 }