repos / zmx

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

commit
15dfe33
parent
1f5f0b8
author
Eric Bower
date
2025-11-26 14:26:51 -0500 EST
feat: send logs to a file with rotation
3 files changed,  +156, -14
M .gitignore
+1, -0
1@@ -9,3 +9,4 @@ commit_msg
2 zig_std_src/
3 ghostty_src/
4 prior_art/
5+.beads/
A src/log.zig
+88, -0
 1@@ -0,0 +1,88 @@
 2+const std = @import("std");
 3+
 4+pub const LogSystem = struct {
 5+    file: ?std.fs.File = null,
 6+    mutex: std.Thread.Mutex = .{},
 7+    current_size: u64 = 0,
 8+    max_size: u64 = 5 * 1024 * 1024, // 5MB
 9+    path: []const u8 = "",
10+    alloc: std.mem.Allocator = undefined,
11+
12+    pub fn init(self: *LogSystem, alloc: std.mem.Allocator, path: []const u8) !void {
13+        self.alloc = alloc;
14+        self.path = try alloc.dupe(u8, path);
15+
16+        const file = std.fs.openFileAbsolute(path, .{ .mode = .read_write }) catch |err| switch (err) {
17+            error.FileNotFound => try std.fs.createFileAbsolute(path, .{ .read = true }),
18+            else => return err,
19+        };
20+
21+        const end_pos = try file.getEndPos();
22+        try file.seekTo(end_pos);
23+        self.current_size = end_pos;
24+        self.file = file;
25+    }
26+
27+    pub fn deinit(self: *LogSystem) void {
28+        if (self.file) |f| f.close();
29+        if (self.path.len > 0) self.alloc.free(self.path);
30+    }
31+
32+    pub fn log(self: *LogSystem, comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime format: []const u8, args: anytype) void {
33+        self.mutex.lock();
34+        defer self.mutex.unlock();
35+
36+        if (self.file == null) {
37+            std.log.defaultLog(level, scope, format, args);
38+            return;
39+        }
40+
41+        if (self.current_size >= self.max_size) {
42+            self.rotate() catch |err| {
43+                std.debug.print("Log rotation failed: {s}\n", .{@errorName(err)});
44+            };
45+        }
46+
47+        const now = std.time.milliTimestamp();
48+        const prefix = "[{d}] [{s}] ({s}): ";
49+        const scope_name = @tagName(scope);
50+        const level_name = level.asText();
51+
52+        const prefix_args = .{
53+            now,
54+            level_name,
55+            scope_name,
56+        };
57+
58+        if (self.file) |f| {
59+            const prefix_len = std.fmt.count(prefix, prefix_args);
60+            const msg_len = std.fmt.count(format, args);
61+            const newline_len = 1;
62+            const total_len = prefix_len + msg_len + newline_len;
63+            self.current_size += total_len;
64+
65+            var buf: [4096]u8 = undefined;
66+            var w = f.writer(&buf);
67+            w.interface.print(prefix ++ format ++ "\n", prefix_args ++ args) catch {};
68+            w.interface.flush() catch {};
69+        }
70+    }
71+
72+    fn rotate(self: *LogSystem) !void {
73+        if (self.file) |f| {
74+            f.close();
75+            self.file = null;
76+        }
77+
78+        const old_path = try std.fmt.allocPrint(self.alloc, "{s}.old", .{self.path});
79+        defer self.alloc.free(old_path);
80+
81+        std.fs.renameAbsolute(self.path, old_path) catch |err| switch (err) {
82+            error.FileNotFound => {},
83+            else => return err,
84+        };
85+
86+        self.file = try std.fs.createFileAbsolute(self.path, .{ .truncate = true, .read = true });
87+        self.current_size = 0;
88+    }
89+};
M src/main.zig
+67, -14
  1@@ -1,7 +1,24 @@
  2 const std = @import("std");
  3 const posix = std.posix;
  4-const ipc = @import("ipc.zig");
  5 const builtin = @import("builtin");
  6+const ipc = @import("ipc.zig");
  7+const log = @import("log.zig");
  8+const RingBuffer = @import("ring_buffer.zig").RingBuffer;
  9+
 10+var log_system = log.LogSystem{};
 11+
 12+pub const std_options: std.Options = .{
 13+    .logFn = zmxLogFn,
 14+};
 15+
 16+fn zmxLogFn(
 17+    comptime level: std.log.Level,
 18+    comptime scope: @Type(.enum_literal),
 19+    comptime format: []const u8,
 20+    args: anytype,
 21+) void {
 22+    log_system.log(level, scope, format, args);
 23+}
 24 
 25 const c = switch (builtin.os.tag) {
 26     .macos => @cImport({
 27@@ -24,10 +41,6 @@ const c = switch (builtin.os.tag) {
 28     }),
 29 };
 30 
 31-// pub const std_options: std.Options = .{
 32-//     .log_level = .err,
 33-// };
 34-
 35 const Client = struct {
 36     alloc: std.mem.Allocator,
 37     socket_fd: i32,
 38@@ -44,13 +57,16 @@ const Client = struct {
 39 
 40 const Cfg = struct {
 41     socket_dir: []const u8 = "/tmp/zmx",
 42+    log_dir: []const u8 = "/tmp/zmx/logs",
 43 
 44     pub fn mkdir(self: *Cfg) !void {
 45-        std.log.info("creating socket dir: socket_dir={s}", .{self.socket_dir});
 46         std.fs.makeDirAbsolute(self.socket_dir) catch |err| switch (err) {
 47-            error.PathAlreadyExists => {
 48-                std.log.info("socket dir already exists", .{});
 49-            },
 50+            error.PathAlreadyExists => {},
 51+            else => return err,
 52+        };
 53+
 54+        std.fs.makeDirAbsolute(self.log_dir) catch |err| switch (err) {
 55+            error.PathAlreadyExists => {},
 56             else => return err,
 57         };
 58     }
 59@@ -96,7 +112,6 @@ const Daemon = struct {
 60 };
 61 
 62 pub fn main() !void {
 63-    std.log.info("running cli", .{});
 64     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 65     const alloc = std.heap.c_allocator;
 66 
 67@@ -107,6 +122,13 @@ pub fn main() !void {
 68     var cfg = Cfg{};
 69     try cfg.mkdir();
 70 
 71+    const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
 72+    defer alloc.free(log_path);
 73+    try log_system.init(alloc, log_path);
 74+    defer log_system.deinit();
 75+
 76+    std.log.info("running cli", .{});
 77+
 78     const cmd = args.next() orelse {
 79         return list(&cfg);
 80     };
 81@@ -159,13 +181,18 @@ fn list(cfg: *Cfg) !void {
 82     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
 83     defer dir.close();
 84     var iter = dir.iterate();
 85+    var hasSessions = false;
 86     while (try iter.next()) |entry| {
 87-        if (try sessionExists(dir, entry.name)) {
 88+        const exists = sessionExists(dir, entry.name) catch continue;
 89+        if (exists) {
 90+            hasSessions = true;
 91             const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name);
 92             defer alloc.free(socket_path);
 93 
 94             const fd = sessionConnect(socket_path) catch |err| {
 95-                std.log.warn("could not connect to session {s}: {s}", .{ entry.name, @errorName(err) });
 96+                var msg_buf: [256]u8 = undefined;
 97+                const msg = try std.fmt.bufPrint(&msg_buf, "could not connect to session {s}: {s}\n", .{ entry.name, @errorName(err) });
 98+                try std.fs.File.stdout().writeAll(msg);
 99                 continue;
100             };
101             defer posix.close(fd);
102@@ -191,12 +218,22 @@ fn list(cfg: *Cfg) !void {
103             }
104 
105             if (info) |i| {
106-                std.log.info("session_name={s}\tpid={d}\tclients={d}", .{ entry.name, i.pid, i.clients_len });
107+                var msg_buf: [256]u8 = undefined;
108+                const msg = try std.fmt.bufPrint(&msg_buf, "session_name={s}\tpid={d}\tclients={d}\n", .{ entry.name, i.pid, i.clients_len });
109+                try std.fs.File.stdout().writeAll(msg);
110             } else {
111-                std.log.info("session_name={s}\tpid=?\tclients=?", .{entry.name});
112+                var msg_buf: [256]u8 = undefined;
113+                const msg = try std.fmt.bufPrint(&msg_buf, "session_name={s}\tpid=?\tclients=?\n", .{entry.name});
114+                try std.fs.File.stdout().writeAll(msg);
115             }
116         }
117     }
118+
119+    if (!hasSessions) {
120+        var msg_buf: [256]u8 = undefined;
121+        const msg = try std.fmt.bufPrint(&msg_buf, "no sessions found in {s}\n", .{cfg.socket_dir});
122+        try std.fs.File.stdout().writeAll(msg);
123+    }
124 }
125 
126 fn detachAll(cfg: *Cfg) !void {
127@@ -216,6 +253,7 @@ fn detachAll(cfg: *Cfg) !void {
128     const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
129     defer alloc.free(socket_path);
130     const client_sock_fd = try sessionConnect(socket_path);
131+    defer posix.close(client_sock_fd);
132     ipc.send(client_sock_fd, .DetachAll, "") catch |err| switch (err) {
133         error.BrokenPipe, error.ConnectionResetByPeer => return,
134         else => return err,
135@@ -239,10 +277,16 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
136     const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
137     defer alloc.free(socket_path);
138     const client_sock_fd = try sessionConnect(socket_path);
139+    defer posix.close(client_sock_fd);
140     ipc.send(client_sock_fd, .Kill, "") catch |err| switch (err) {
141         error.BrokenPipe, error.ConnectionResetByPeer => return,
142         else => return err,
143     };
144+
145+    var buf: [4096]u8 = undefined;
146+    var w = std.fs.File.stdout().writer(&buf);
147+    try w.interface.print("killed session {s}\n", .{session_name});
148+    try w.interface.flush();
149 }
150 
151 fn attach(daemon: *Daemon) !void {
152@@ -278,6 +322,14 @@ fn attach(daemon: *Daemon) !void {
153         const pid = try posix.fork();
154         if (pid == 0) { // child
155             _ = try posix.setsid();
156+
157+            log_system.deinit();
158+            const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name});
159+            defer daemon.alloc.free(session_log_name);
160+            const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name });
161+            defer daemon.alloc.free(session_log_path);
162+            try log_system.init(daemon.alloc, session_log_path);
163+
164             const pty_fd = try spawnPty(daemon);
165             defer {
166                 posix.close(server_sock_fd);
167@@ -707,6 +759,7 @@ fn spawnPty(daemon: *Daemon) !c_int {
168 fn sessionConnect(fname: []const u8) !i32 {
169     var unix_addr = try std.net.Address.initUnix(fname);
170     const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
171+    errdefer posix.close(socket_fd);
172     posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
173         if (err == error.ConnectionRefused) {
174             std.log.err("unable to connect to unix socket fname={s}", .{fname});