Commit 0d66096
Eric Bower
·
2026-07-27 09:28:10 -0400 EDT
parent 9e5eb55
refactor(daemon): restructure io and allocators Because we perform a double-fork when spawning a daemon we have to be careful about multithreading and mutex locks within io and allocators or else we could end up with a deadlock. I don't fully understand how this happens within the DebugAllocator but I've hit deadlocks previously. So I restructured the Daemon to not store io or an allocator and made it much more clear what is going on and why we have to create a new allocator after the double-fork. I also extracted the daemonize logic into its own file and set of fns mainly because that code doesn't need to change much and it is mostly fork() machinery.
11 files changed,
+1818,
-1743
+10,
-10
1@@ -13,7 +13,7 @@ const macos_targets: []const std.Target.Query = &.{
2
3 pub fn build(b: *std.Build) void {
4 const target = b.standardTargetOptions(.{});
5- const is_macos = target.result.os.tag == .macos;
6+ // const is_macos = target.result.os.tag == .macos;
7 const optimize = b.standardOptimizeOption(.{});
8 const version = b.option([]const u8, "version", "Version string for release") orelse
9 @as([]const u8, build_zig_zon.version);
10@@ -51,8 +51,8 @@ pub fn build(b: *std.Build) void {
11 const run_step = b.step("run", "Run the app");
12 const exe = b.addExecutable(.{
13 .name = "zmx",
14- .use_llvm = true,
15- .use_lld = !is_macos,
16+ // .use_llvm = true,
17+ // .use_lld = !is_macos,
18 .root_module = exe_mod,
19 });
20
21@@ -85,8 +85,8 @@ pub fn build(b: *std.Build) void {
22 );
23 const exe_unit_tests = b.addTest(.{
24 .root_module = test_module,
25- .use_llvm = true,
26- .use_lld = !is_macos,
27+ // .use_llvm = true,
28+ // .use_lld = !is_macos,
29 });
30
31 const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
32@@ -98,8 +98,8 @@ pub fn build(b: *std.Build) void {
33 const check = b.step("check", "Check if zmx compiles");
34 const exe_check = b.addExecutable(.{
35 .name = "zmx",
36- .use_llvm = true,
37- .use_lld = !is_macos,
38+ // .use_llvm = true,
39+ // .use_lld = !is_macos,
40 .root_module = exe_mod,
41 });
42
43@@ -136,11 +136,11 @@ pub fn build(b: *std.Build) void {
44 release_mod.addImport("ghostty-vt", release_dep.module("ghostty-vt"));
45 }
46
47- const is_local_macos = resolved.result.os.tag == .macos;
48+ // const is_local_macos = resolved.result.os.tag == .macos;
49 const release_exe = b.addExecutable(.{
50 .name = "zmx",
51- .use_llvm = true,
52- .use_lld = !is_local_macos,
53+ // .use_llvm = true,
54+ // .use_lld = !is_local_macos,
55 .root_module = release_mod,
56 });
57
+133,
-0
1@@ -0,0 +1,133 @@
2+/// Cfg is zmx's configuration container.
3+///
4+/// The purpose of this container is to hold anything that can be modified by the user.
5+pub const Cfg = @This();
6+
7+const std = @import("std");
8+const lib_posix = @import("posix.zig");
9+const cross = @import("cross.zig");
10+
11+socket_dir: []const u8,
12+log_dir: []const u8,
13+max_scrollback: usize = 10_000_000,
14+dir_mode: u32 = 0o750,
15+log_mode: u32 = 0o640,
16+
17+pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
18+ const socket_dir = try socketDir(alloc);
19+ errdefer alloc.free(socket_dir);
20+ const log_dir = try logDir(alloc);
21+ errdefer alloc.free(log_dir);
22+
23+ const dir_mode = if (lib_posix.getenv("ZMX_DIR_MODE")) |m|
24+ std.fmt.parseInt(u32, m, 8) catch 0o750
25+ else
26+ 0o750;
27+
28+ const log_mode = if (lib_posix.getenv("ZMX_LOG_MODE")) |m|
29+ std.fmt.parseInt(u32, m, 8) catch 0o640
30+ else
31+ 0o640;
32+
33+ var cfg = Cfg{
34+ .socket_dir = socket_dir,
35+ .log_dir = log_dir,
36+ .dir_mode = dir_mode,
37+ .log_mode = log_mode,
38+ };
39+
40+ try cfg.mkdir(io);
41+
42+ return cfg;
43+}
44+
45+fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
46+ const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
47+ const uid = lib_posix.getuid();
48+
49+ const socket_dir: []const u8 = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
50+ try alloc.dupe(u8, zmxdir)
51+ else if (lib_posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
52+ try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
53+ else
54+ try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
55+
56+ return socket_dir;
57+}
58+
59+fn logDir(alloc: std.mem.Allocator) ![]const u8 {
60+ const log_dir = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
61+ try std.fmt.allocPrint(alloc, "{s}/logs", .{zmxdir})
62+ else if (lib_posix.getenv("XDG_STATE_HOME")) |xdg_state_home|
63+ try std.fmt.allocPrint(alloc, "{s}/zmx/logs", .{xdg_state_home})
64+ else if (lib_posix.getenv("HOME")) |home_dir|
65+ try std.fmt.allocPrint(alloc, "{s}/.local/state/zmx/logs", .{home_dir})
66+ else fallback: {
67+ // This is the last resort: falling back to /tmp/$UID if HOME is unset.
68+ const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
69+ const uid = lib_posix.getuid();
70+ break :fallback try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
71+ };
72+
73+ return log_dir;
74+}
75+
76+pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
77+ if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
78+ if (self.log_dir.len > 0) alloc.free(self.log_dir);
79+}
80+
81+pub fn mkdir(self: *Cfg, io: std.Io) !void {
82+ const sock_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
83+ try mkdirAll(io, self.socket_dir, sock_perms);
84+ const log_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
85+ try mkdirAll(io, self.log_dir, log_perms);
86+}
87+
88+fn mkdirAll(io: std.Io, sub_dir_path: []const u8, permissions: std.Io.Dir.Permissions) !void {
89+ var it = std.fs.path.componentIterator(sub_dir_path);
90+ var component = it.last() orelse return error.BadPathName;
91+ while (true) {
92+ std.Io.Dir.createDirAbsolute(io, component.path, permissions) catch |err| switch (err) {
93+ error.PathAlreadyExists => {},
94+ error.FileNotFound => |e| {
95+ component = it.previous() orelse return e;
96+ continue;
97+ },
98+ else => |e| return e,
99+ };
100+ component = it.next() orelse return;
101+ }
102+}
103+
104+test "Cfg.init uses default modes when env vars are not set" {
105+ const alloc = std.testing.allocator;
106+
107+ // Ensure they are not set
108+ _ = cross.c.unsetenv("ZMX_DIR_MODE");
109+ _ = cross.c.unsetenv("ZMX_LOG_MODE");
110+
111+ var cfg = try Cfg.init(alloc, std.testing.io);
112+ defer cfg.deinit(alloc);
113+
114+ try std.testing.expectEqual(@as(u32, 0o750), cfg.dir_mode);
115+ try std.testing.expectEqual(@as(u32, 0o640), cfg.log_mode);
116+}
117+
118+test "Cfg.init uses custom modes from env vars" {
119+ const alloc = std.testing.allocator;
120+
121+ // Set custom octal values
122+ _ = cross.c.setenv("ZMX_DIR_MODE", "770", 1);
123+ _ = cross.c.setenv("ZMX_LOG_MODE", "660", 1);
124+ defer {
125+ _ = cross.c.unsetenv("ZMX_DIR_MODE");
126+ _ = cross.c.unsetenv("ZMX_LOG_MODE");
127+ }
128+
129+ var cfg = try Cfg.init(alloc, std.testing.io);
130+ defer cfg.deinit(alloc);
131+
132+ try std.testing.expectEqual(@as(u32, 0o770), cfg.dir_mode);
133+ try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
134+}
+211,
-0
1@@ -0,0 +1,211 @@
2+const std = @import("std");
3+const lib_posix = @import("posix.zig");
4+const Cfg = @import("cfg.zig");
5+const socket = @import("socket.zig");
6+const ipc = @import("ipc.zig");
7+const assert = std.debug.assert;
8+const log = @import("log.zig");
9+const cross = @import("cross.zig");
10+
11+const Cmd = struct {
12+ file: [*:0]const u8,
13+ argv_ptr: [*:null]const ?[*:0]const u8,
14+};
15+
16+pub fn createCmdZ(def_shell: []const u8, is_task_mode: bool, command: ?[]const []const u8) !Cmd {
17+ const gpa = std.heap.c_allocator;
18+
19+ if (command) |cmd_args| {
20+ const argv = try gpa.allocSentinel(?[*:0]const u8, cmd_args.len, null);
21+ for (cmd_args, 0..) |arg, i| {
22+ argv[i] = try gpa.dupeZ(u8, arg);
23+ }
24+ return .{
25+ .file = argv[0].?,
26+ .argv_ptr = argv.ptr,
27+ };
28+ }
29+
30+ const z = try std.fmt.allocPrintSentinel(gpa, "{s}", .{def_shell}, 0);
31+ const shell: [:0]const u8 = if (is_task_mode) "bash" else z;
32+
33+ // Use "-shellname" as argv[0] to signal login shell (traditional method)
34+ const login_shell = try std.fmt.allocPrintSentinel(gpa, "-{s}", .{std.fs.path.basename(shell)}, 0);
35+ const argv = try gpa.allocSentinel(?[*:0]const u8, 1, null);
36+ argv[0] = login_shell.ptr;
37+
38+ return .{
39+ .file = shell,
40+ .argv_ptr = argv,
41+ };
42+}
43+
44+/// Runs in the forked child. Either execs or returns an error (caller
45+/// must exit on error -- returning would fall through to parent code).
46+fn exec(sesh_name: []const u8, cmd: Cmd) !noreturn {
47+ const gpa = std.heap.c_allocator;
48+
49+ // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
50+ // exec. Restore the default so the shell and its children behave
51+ // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
52+ const dfl: lib_posix.Sigaction = .{
53+ .handler = .{ .handler = lib_posix.SIG.DFL },
54+ .mask = lib_posix.sigemptyset(),
55+ .flags = 0,
56+ };
57+ lib_posix.sigaction(lib_posix.SIG.PIPE, &dfl, null);
58+
59+ const session_env = try std.fmt.allocPrintSentinel(
60+ gpa,
61+ "ZMX_SESSION={s}",
62+ .{sesh_name},
63+ 0,
64+ );
65+ _ = cross.c.putenv(session_env.ptr);
66+
67+ const err = lib_posix.execvpeZ(cmd.file, cmd.argv_ptr, std.c.environ);
68+ std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd.file, @errorName(err) });
69+ lib_posix.exit(1);
70+}
71+
72+pub const PtyInfo = struct {
73+ master_fd: c_int = undefined,
74+ pid: c_int = undefined,
75+};
76+
77+/// spawnPty runs forkpty() and executes the shell or shell command the user
78+/// provides.
79+///
80+/// This is the second fork in the double-fork technique explained in the
81+/// daemonize() comment.
82+pub fn spawnPty(sesh_name: []const u8, cmd: Cmd) !PtyInfo {
83+ const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
84+ var ws: cross.c.struct_winsize = .{
85+ .ws_row = size.rows,
86+ .ws_col = size.cols,
87+ .ws_xpixel = size.xpixel,
88+ .ws_ypixel = size.ypixel,
89+ };
90+
91+ var master_fd: c_int = undefined;
92+ const pid = cross.forkpty(&master_fd, null, null, &ws);
93+ if (pid < 0) {
94+ return error.ForkPtyFailed;
95+ }
96+
97+ if (pid == 0) { // child pid code path
98+ // In the forked child, ANY error must exit rather than propagate:
99+ // a returned error falls through to the parent code path below,
100+ // running a second daemon on the same socket (or worse, hitting
101+ // errdefers that delete the parent's socket file).
102+ exec(sesh_name, cmd) catch |err| {
103+ std.log.err("child setup failed: {s}", .{@errorName(err)});
104+ lib_posix.exit(1);
105+ };
106+ unreachable; // exec() either execs or exits, never returns ok
107+ }
108+ // master pid code path
109+ std.log.info("pty spawned session={s} pid={d}", .{ sesh_name, pid });
110+
111+ // make pty non-blocking
112+ const flags = try lib_posix.fcntl(master_fd, lib_posix.F.GETFL, 0);
113+ _ = try lib_posix.fcntl(master_fd, lib_posix.F.SETFL, flags | lib_posix.O_NONBLOCK);
114+
115+ return .{
116+ .master_fd = master_fd,
117+ .pid = pid,
118+ };
119+}
120+
121+/// daemonize is the first fork in a double-fork technique to create a
122+/// completely disconnected session (group of processes).
123+///
124+/// When launching a daemon, you normally set the child process of the fork to
125+/// be the session leader via setsid() which creates a new session that does
126+/// *not* have a controlling terminal. This is important because we don't want
127+/// a controlling terminal for our daemon or else our daemon could receive
128+/// signals to shutdown when the controlling terminal closes.
129+///
130+/// However, if the first fork's child process is also the daemon process, then
131+/// it's technically possible for the daemon to open a terminal device
132+/// (e.g. open("/dev/console", O_RDWR)) and then it would acquire a controlling
133+/// terminal! A controlling terminal would expose the daemon to
134+/// terminal-generated signals (e.g. SIGINT) or SIGHUP from terminal disconnect
135+/// which could kill the daemon.
136+///
137+/// By forking a second time, the grandchild process (the daemon) is not the
138+/// session leader. Per POSIX, only a process that is the session leader can
139+/// acquire a controlling terminal.
140+///
141+/// Apparently this is considered "being paranoid" but appears to be a standard
142+/// practice for daemons so we're doing it anyway.
143+///
144+/// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11_01_03
145+/// https://stackoverflow.com/a/16317668
146+pub fn daemonize(sesh_name: []const u8, cmd: Cmd, keep_fds_open: []i32) !PtyInfo {
147+ // creates the daemon
148+ const pid = try lib_posix.fork();
149+ assert(pid != -1);
150+
151+ if (pid > 0) { // parent (client)
152+ // cannot use a passed-in io or alloc after a fork so we create what we need
153+ // after the fork()
154+ var threaded: std.Io.Threaded = .init_single_threaded;
155+ defer threaded.deinit();
156+ const io = threaded.io();
157+ std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .real) catch unreachable;
158+ return error.IsClientProc;
159+ }
160+
161+ assert(pid == 0); // child (daemon's parent in double-fork)
162+ // becomes the session leader and detaches process from its controlling terminal
163+ _ = try lib_posix.setsid();
164+
165+ // Redirect stdin/stdout/stderr to /dev/null. The daemon
166+ // communicates via its unix socket, not stdio. Without
167+ // this, any pipe on FDs 0-2 (e.g. from bats' `run`
168+ // keyword) stays open for the daemon's lifetime, causing
169+ // the caller to hang waiting for EOF.
170+ {
171+ const devnull = lib_posix.open(
172+ "/dev/null",
173+ .{ .ACCMODE = .RDWR },
174+ 0,
175+ ) catch |err| {
176+ std.log.warn("failed to open /dev/null: {s}", .{@errorName(err)});
177+ return err;
178+ };
179+ inline for (.{ lib_posix.STDIN_FILENO, lib_posix.STDOUT_FILENO, lib_posix.STDERR_FILENO }) |fd| {
180+ _ = lib_posix.dup2(devnull, fd) catch |err| {
181+ std.log.warn("dup2 /dev/null -> {d}: {s}", .{ fd, @errorName(err) });
182+ return err;
183+ };
184+ }
185+ var found = false;
186+ for (keep_fds_open) |fd| {
187+ if (devnull == fd) found = true;
188+ }
189+ if (devnull > 2 and !found) lib_posix.close(devnull);
190+ }
191+
192+ // Close file descriptors inherited from the parent that the
193+ // daemon doesn't need. This prevents test harnesses (like
194+ // bats) from hanging: they wait for their internal FDs (3+)
195+ // to close before exiting.
196+ //
197+ // Skip any fds that the caller wants to keep open, e.g. server_sock_fd
198+ // (needed for IPC) and dir.fd (needed to delete the socket file on
199+ // shutdown).
200+ {
201+ var fd: i32 = 3;
202+ while (fd < 64) : (fd += 1) {
203+ var found = false;
204+ for (keep_fds_open) |kfd| {
205+ if (fd == kfd) found = true;
206+ }
207+ if (!found) _ = std.c.close(fd);
208+ }
209+ }
210+
211+ return spawnPty(sesh_name, cmd);
212+}
+2,
-2
1@@ -94,7 +94,7 @@ pub fn send(fd: i32, tag: Tag, data: []const u8) !void {
2 }
3
4 pub fn appendMessage(
5- alloc: std.mem.Allocator,
6+ gpa: std.mem.Allocator,
7 list: *std.ArrayList(u8),
8 tag: Tag,
9 data: []const u8,
10@@ -105,7 +105,7 @@ pub fn appendMessage(
11 };
12 // Guarantee capacity for header + payload in one check to avoid
13 // intermediate realloc between the two appends on the hot path.
14- try list.ensureTotalCapacity(alloc, list.items.len + @sizeOf(Header) + data.len);
15+ try list.ensureTotalCapacity(gpa, list.items.len + @sizeOf(Header) + data.len);
16 list.appendSliceAssumeCapacity(std.mem.asBytes(&header));
17 if (data.len > 0) {
18 list.appendSliceAssumeCapacity(data);
+18,
-18
1@@ -1,19 +1,28 @@
2 const std = @import("std");
3
4+pub var log_system = LogSystem{};
5+
6+pub fn zmxLogFn(
7+ comptime level: std.log.Level,
8+ comptime scope: anytype,
9+ comptime format: []const u8,
10+ args: anytype,
11+) void {
12+ log_system.log(level, scope, format, args) catch {};
13+}
14+
15 pub const LogSystem = struct {
16 file: ?std.Io.File = null,
17 mutex: std.Io.Mutex = .init,
18 current_size: u64 = 0,
19- max_size: u64 = 5 * 1024 * 1024, // 5MB
20+ max_size: u64 = 2 * 1024 * 1024, // 2MB
21 path: []const u8 = "",
22- alloc: std.mem.Allocator = undefined,
23 io: std.Io = undefined,
24 mode: std.Io.File.Permissions = std.Io.File.Permissions.fromMode(0o640),
25
26- pub fn init(self: *LogSystem, alloc: std.mem.Allocator, io: std.Io, path: []const u8, mode: std.Io.File.Permissions) !void {
27- self.alloc = alloc;
28+ pub fn init(self: *LogSystem, io: std.Io, path: []const u8, mode: std.Io.File.Permissions) !void {
29 self.io = io;
30- self.path = try alloc.dupe(u8, path);
31+ self.path = path;
32 self.mode = mode;
33
34 const file = std.Io.Dir.openFileAbsolute(self.io, path, .{ .mode = .read_write }) catch |err| switch (err) {
35@@ -35,7 +44,6 @@ pub const LogSystem = struct {
36
37 pub fn deinit(self: *LogSystem) void {
38 if (self.file) |f| std.Io.File.close(f, self.io);
39- if (self.path.len > 0) self.alloc.free(self.path);
40 }
41
42 pub fn log(
43@@ -54,12 +62,12 @@ pub const LogSystem = struct {
44 }
45
46 if (self.current_size >= self.max_size) {
47- self.rotate() catch |err| {
48- std.debug.print("Log rotation failed: {s}\n", .{@errorName(err)});
49+ self.wipe() catch |err| {
50+ std.debug.print("Log wipe failed: {s}\n", .{@errorName(err)});
51 };
52 }
53
54- const now: i64 = @intCast(@divTrunc(std.Io.Timestamp.now(self.io, .real).nanoseconds, std.time.ns_per_ms));
55+ const now: std.Io.Timestamp = .now(self.io, .real);
56 const prefix = "[{d}] [{s}] ({s}): ";
57 const scope_name = @tagName(scope);
58 const level_name = level.asText();
59@@ -84,20 +92,12 @@ pub const LogSystem = struct {
60 }
61 }
62
63- fn rotate(self: *LogSystem) !void {
64+ fn wipe(self: *LogSystem) !void {
65 if (self.file) |f| {
66 std.Io.File.close(f, self.io);
67 self.file = null;
68 }
69
70- const old_path = try std.fmt.allocPrint(self.alloc, "{s}.old", .{self.path});
71- defer self.alloc.free(old_path);
72-
73- std.Io.Dir.renameAbsolute(self.path, old_path, self.io) catch |err| switch (err) {
74- error.FileNotFound => {},
75- else => return err,
76- };
77-
78 self.file = try std.Io.Dir.createFileAbsolute(
79 self.io,
80 self.path,
+1212,
-0
1@@ -0,0 +1,1212 @@
2+const std = @import("std");
3+const ghostty_vt = @import("ghostty-vt");
4+const ipc = @import("ipc.zig");
5+const log = @import("log.zig");
6+const util = @import("util.zig");
7+const cross = @import("cross.zig");
8+const socket = @import("socket.zig");
9+const label = @import("label.zig");
10+const lib_posix = @import("posix.zig");
11+const Cfg = @import("cfg.zig");
12+const signal = @import("signal.zig");
13+const assert = std.debug.assert;
14+const daemonize = @import("daemonize.zig");
15+const builtin = @import("builtin");
16+
17+/// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
18+/// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
19+pub fn clientLoop(client_sock_fd: i32) !ClientResult {
20+ std.log.info("client loop fd={d}", .{client_sock_fd});
21+ const gpa: std.mem.Allocator = blk: {
22+ if (builtin.mode == .Debug) {
23+ const GPA = std.heap.DebugAllocator(.{});
24+ const Static = struct {
25+ var gpa: GPA = .{};
26+ };
27+ break :blk Static.gpa.allocator();
28+ }
29+ break :blk std.heap.c_allocator;
30+ };
31+ defer lib_posix.close(client_sock_fd);
32+
33+ try signal.openSignalPipe();
34+ signal.installWakeHandler(@intFromEnum(lib_posix.SIG.WINCH));
35+
36+ // Make socket non-blocking to avoid blocking on writes
37+ var sock_flags = try lib_posix.fcntl(client_sock_fd, lib_posix.F.GETFL, 0);
38+ sock_flags |= lib_posix.O_NONBLOCK;
39+ _ = try lib_posix.fcntl(client_sock_fd, lib_posix.F.SETFL, sock_flags);
40+
41+ // Buffer for outgoing socket writes
42+ var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
43+ defer sock_write_buf.deinit(gpa);
44+
45+ // Send init message with terminal size (buffered)
46+ const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
47+ try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size));
48+
49+ var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4);
50+ defer poll_fds.deinit(gpa);
51+
52+ var read_buf = try ipc.SocketBuffer.init(gpa);
53+ defer read_buf.deinit();
54+
55+ var stdout_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
56+ defer stdout_buf.deinit(gpa);
57+
58+ const stdin_fd = lib_posix.STDIN_FILENO;
59+
60+ // Make stdin non-blocking. O_NONBLOCK is set on the open file description,
61+ // which is shared with the parent shell; restore on exit to avoid
62+ // corrupting the parent's stdin.
63+ const stdin_orig_flags = try lib_posix.fcntl(stdin_fd, lib_posix.F.GETFL, 0);
64+ _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | lib_posix.O_NONBLOCK);
65+ defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
66+
67+ while (true) {
68+ poll_fds.clearRetainingCapacity();
69+
70+ try poll_fds.append(gpa, .{
71+ .fd = stdin_fd,
72+ .events = lib_posix.POLL.IN,
73+ .revents = 0,
74+ });
75+
76+ // Poll socket for read, and also for write if we have pending data
77+ var sock_events: i16 = lib_posix.POLL.IN;
78+ if (sock_write_buf.items.len > 0) {
79+ sock_events |= lib_posix.POLL.OUT;
80+ }
81+ try poll_fds.append(gpa, .{
82+ .fd = client_sock_fd,
83+ .events = sock_events,
84+ .revents = 0,
85+ });
86+
87+ try poll_fds.append(gpa, .{ .fd = signal.sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
88+
89+ if (stdout_buf.items.len > 0) {
90+ try poll_fds.append(gpa, .{
91+ .fd = lib_posix.STDOUT_FILENO,
92+ .events = lib_posix.POLL.OUT,
93+ .revents = 0,
94+ });
95+ }
96+
97+ _ = try lib_posix.poll(poll_fds.items, -1);
98+
99+ if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
100+ signal.drainSignalPipe();
101+ const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
102+ try ipc.appendMessage(gpa, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
103+ }
104+
105+ // Handle stdin -> socket (Input)
106+ const inp_flags = (lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL);
107+ if (poll_fds.items[0].revents & inp_flags != 0) {
108+ var buf: [4096]u8 = undefined;
109+ const n_opt: ?usize = lib_posix.read(stdin_fd, &buf) catch |err| blk: {
110+ if (err == error.WouldBlock) break :blk null;
111+ return err;
112+ };
113+
114+ if (n_opt) |n| {
115+ if (n > 0) {
116+ // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
117+ if (util.isCtrlBackslash(buf[0..n])) {
118+ std.log.info("detach key detected", .{});
119+ try ipc.appendMessage(gpa, &sock_write_buf, .Detach, "");
120+ } else {
121+ try ipc.appendMessage(gpa, &sock_write_buf, .Input, buf[0..n]);
122+ }
123+ } else {
124+ std.log.info("eof stdin", .{});
125+ // EOF on stdin
126+ return ClientResult{ .kind = .detach, .session_name = null };
127+ }
128+ }
129+ }
130+
131+ // Handle socket read (incoming Output messages from daemon)
132+ if (poll_fds.items[1].revents & lib_posix.POLL.IN != 0) {
133+ const n = read_buf.read(client_sock_fd) catch |err| {
134+ if (err == error.WouldBlock) continue;
135+ if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
136+ return ClientResult{ .kind = .detach, .session_name = null };
137+ }
138+ std.log.err("daemon read err={s}", .{@errorName(err)});
139+ return err;
140+ };
141+ if (n == 0) {
142+ std.log.info("server closed connection", .{});
143+ // Server closed connection
144+ return ClientResult{ .kind = .detach, .session_name = null };
145+ }
146+
147+ while (read_buf.next()) |msg| {
148+ switch (msg.header.tag) {
149+ .Output => {
150+ if (msg.payload.len > 0) {
151+ try stdout_buf.appendSlice(gpa, msg.payload);
152+ }
153+ },
154+ .Resize => {
155+ // daemon is asking for the client's window size usually in response
156+ // to this client being set as leader.
157+ const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
158+ try ipc.appendMessage(
159+ gpa,
160+ &sock_write_buf,
161+ .Resize,
162+ std.mem.asBytes(&next_size),
163+ );
164+ },
165+ .Switch => {
166+ std.log.info("switch session", .{});
167+ return ClientResult{ .kind = .switch_session, .session_name = try gpa.dupe(u8, msg.payload) };
168+ },
169+ else => {},
170+ }
171+ }
172+ }
173+
174+ // Handle socket write (flush buffered messages to daemon)
175+ if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
176+ if (sock_write_buf.items.len > 0) {
177+ const n = lib_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+ std.log.info("connection reset or broken pipe", .{});
181+ return ClientResult{ .kind = .detach, .session_name = null };
182+ }
183+ return err;
184+ };
185+ if (n > 0) {
186+ try sock_write_buf.replaceRange(gpa, 0, n, &[_]u8{});
187+ }
188+ }
189+ }
190+
191+ if (stdout_buf.items.len > 0) {
192+ const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
193+ if (err == error.WouldBlock) break :blk 0;
194+ return err;
195+ };
196+ if (n > 0) {
197+ try stdout_buf.replaceRange(gpa, 0, n, &[_]u8{});
198+ }
199+ }
200+
201+ if (poll_fds.items[1].revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
202+ std.log.info("poll hup|err|nval", .{});
203+ return ClientResult{ .kind = .detach, .session_name = null };
204+ }
205+ }
206+}
207+
208+/// dameonLoop is what the daemon runs to send and receive ipc commands from its corresponding
209+/// clients. It uses poll() as its non-blocking mechanism.
210+fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_fd: lib_posix.socket_t, pty_fd: i32) !void {
211+ std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
212+
213+ try signal.openSignalPipe();
214+ signal.installWakeHandler(@intFromEnum(lib_posix.SIG.TERM));
215+ var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 8);
216+ defer poll_fds.deinit(gpa);
217+
218+ const init_size = ipc.getTerminalSize(pty_fd);
219+ var term = try ghostty_vt.Terminal.init(io, gpa, .{
220+ .cols = init_size.cols,
221+ .rows = init_size.rows,
222+ .max_scrollback = daemon.cfg.max_scrollback,
223+ });
224+ defer term.deinit(gpa);
225+ var vt_stream = term.vtStream();
226+ defer vt_stream.deinit();
227+
228+ // Carries the tail of the previous PTY read so the task-exit marker
229+ // search below can see across a read() boundary. Sized to comfortably
230+ // hold "ZMX_TASK_COMPLETED:" (19 bytes) plus a u8 exit code and CRLF.
231+ var marker_carry: [32]u8 = undefined;
232+ var marker_carry_len: usize = 0;
233+
234+ daemon_loop: while (daemon.running) {
235+ poll_fds.clearRetainingCapacity();
236+
237+ try poll_fds.append(gpa, .{
238+ .fd = server_sock_fd,
239+ .events = lib_posix.POLL.IN,
240+ .revents = 0,
241+ });
242+
243+ var pty_events: i16 = lib_posix.POLL.IN;
244+ if (daemon.pty_write_buf.items.len > 0) {
245+ pty_events |= lib_posix.POLL.OUT;
246+ }
247+ try poll_fds.append(gpa, .{
248+ .fd = pty_fd,
249+ .events = pty_events,
250+ .revents = 0,
251+ });
252+
253+ try poll_fds.append(gpa, .{ .fd = signal.sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
254+
255+ for (daemon.clients.items) |client| {
256+ var events: i16 = lib_posix.POLL.IN;
257+ if (client.has_pending_output) {
258+ events |= lib_posix.POLL.OUT;
259+ }
260+ try poll_fds.append(gpa, .{
261+ .fd = client.socket_fd,
262+ .events = events,
263+ .revents = 0,
264+ });
265+ }
266+
267+ _ = try lib_posix.poll(poll_fds.items, -1);
268+
269+ if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
270+ signal.drainSignalPipe();
271+ std.log.info(
272+ "SIGTERM received, shutting down gracefully session={s}",
273+ .{daemon.session_name},
274+ );
275+ break :daemon_loop;
276+ }
277+
278+ if (poll_fds.items[0].revents & (lib_posix.POLL.ERR | lib_posix.POLL.HUP | lib_posix.POLL.NVAL) != 0) {
279+ std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
280+ break :daemon_loop;
281+ } else if (poll_fds.items[0].revents & lib_posix.POLL.IN != 0) {
282+ const client_fd = try lib_posix.accept(
283+ server_sock_fd,
284+ null,
285+ null,
286+ lib_posix.SOCK.NONBLOCK | lib_posix.SOCK.CLOEXEC,
287+ );
288+ const client = try gpa.create(Client);
289+ client.* = Client{
290+ .alloc = gpa,
291+ .socket_fd = client_fd,
292+ .read_buf = try ipc.SocketBuffer.init(gpa),
293+ .write_buf = undefined,
294+ };
295+ // 64KB initial capacity lets ~15 broadcast cycles (N_TTY_BUF_SIZE reads
296+ // * header) accumulate before the first ArrayList growth. The write
297+ // buffer is userspace-only: it drains via POLLOUT to the client socket,
298+ // which has no corresponding kernel-imposed per-write limit.
299+ client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 65536);
300+ try daemon.clients.append(gpa, client);
301+ std.log.info(
302+ "client connected fd={d} total={d}",
303+ .{ client_fd, daemon.clients.items.len },
304+ );
305+ }
306+
307+ const inp_flags = lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL;
308+ if (poll_fds.items[1].revents & inp_flags != 0) {
309+ // Read from PTY. Buffer is sized to N_TTY_BUF_SIZE (4096): the hard
310+ // kernel limit for the N_TTY line discipline. A larger buffer doesn't
311+ // help: each read() from a PTY master returns at most 4096 bytes
312+ // regardless of the userspace buffer size.
313+ var buf: [4096]u8 = undefined;
314+ const n_opt: ?usize = lib_posix.read(pty_fd, &buf) catch |err| blk: {
315+ if (err == error.WouldBlock) break :blk null;
316+ break :blk 0;
317+ };
318+
319+ if (n_opt) |n| {
320+ if (n == 0) {
321+ // EOF: Shell exited
322+ std.log.info("shell exited pty_fd={d}", .{pty_fd});
323+ // Let the rest of this poll iteration complete so client
324+ // write buffers are flushed via the normal POLLOUT path.
325+ // On the next iteration, daemon.running will be false.
326+ daemon.running = false;
327+ } else {
328+ // Feed PTY output to terminal emulator for state tracking
329+ vt_stream.nextSlice(buf[0..n]);
330+ daemon.has_pty_output = true;
331+
332+ // When no real terminal client has attached yet, respond to
333+ // terminal queries (e.g. DA1/DA2) on behalf of the terminal.
334+ // This prevents fish from waiting 10s for unanswered queries.
335+ // `has_terminal_client` is only set when a client sends .Init
336+ // (a real zmx attach), not when a `zmx run` tail-only client
337+ // connects.
338+ if (!daemon.has_terminal_client and
339+ daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX)
340+ {
341+ util.respondToDeviceAttributes(gpa, &daemon.pty_write_buf, buf[0..n]);
342+ }
343+
344+ // In run mode, scan output for exit code marker. The marker
345+ // can straddle two PTY reads (more likely under a throttled
346+ // scheduler, e.g. containers), so prepend the tail carried
347+ // over from the previous read before searching.
348+ if (daemon.is_task_mode and daemon.task_exit_code == null) {
349+ var scan_buf: [marker_carry.len + buf.len]u8 = undefined;
350+ @memcpy(scan_buf[0..marker_carry_len], marker_carry[0..marker_carry_len]);
351+ @memcpy(scan_buf[marker_carry_len..][0..n], buf[0..n]);
352+ const scan_len = marker_carry_len + n;
353+
354+ if (util.findTaskExitMarker(scan_buf[0..scan_len])) |exit_code| {
355+ daemon.task_exit_code = exit_code;
356+ daemon.task_ended_at = @intCast(std.Io.Timestamp.now(io, .real).toSeconds());
357+
358+ std.log.info("task completed exit_code={d}", .{exit_code});
359+
360+ // Notify connected clients
361+ for (daemon.clients.items) |c| {
362+ ipc.appendMessage(gpa, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
363+ c.has_pending_output = true;
364+ }
365+ }
366+
367+ marker_carry_len = @min(marker_carry.len, scan_len);
368+ @memcpy(
369+ marker_carry[0..marker_carry_len],
370+ scan_buf[scan_len - marker_carry_len .. scan_len],
371+ );
372+ }
373+
374+ // Broadcast data to all clients.
375+ // Rewrite OSC 133;A to include redraw=0 so the outer terminal
376+ // does not clear prompt lines on resize (issue #111).
377+ const broadcast_data = util.rewritePromptRedraw(gpa, buf[0..n]) orelse buf[0..n];
378+ defer if (broadcast_data.ptr != buf[0..n].ptr) gpa.free(broadcast_data);
379+ for (daemon.clients.items) |client| {
380+ ipc.appendMessage(gpa, &client.write_buf, .Output, broadcast_data) catch |err| {
381+ std.log.warn(
382+ "failed to buffer output for client err={s}",
383+ .{@errorName(err)},
384+ );
385+ continue;
386+ };
387+ client.has_pending_output = true;
388+ }
389+ }
390+ }
391+ }
392+
393+ if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
394+ while (daemon.pty_write_buf.items.len > 0) {
395+ const n = lib_posix.write(pty_fd, daemon.pty_write_buf.items) catch |err| {
396+ if (err != error.WouldBlock) {
397+ std.log.warn("pty write failed: {s}", .{@errorName(err)});
398+ daemon.pty_write_buf.clearRetainingCapacity();
399+ }
400+ break;
401+ };
402+ if (n == 0) break;
403+ daemon.pty_write_buf.replaceRange(gpa, 0, n, &[_]u8{}) catch unreachable;
404+ }
405+ }
406+
407+ var i: usize = daemon.clients.items.len;
408+ // Only iterate over clients that were present when poll_fds was constructed
409+ // poll_fds contains [server, pty, sig_pipe, client0, client1, ...]
410+ // So number of clients in poll_fds is poll_fds.items.len - 3
411+ const num_polled_clients = poll_fds.items.len - 3;
412+ if (i > num_polled_clients) {
413+ // If we have more clients than polled (i.e. we just accepted one), start from the
414+ // polled ones
415+ i = num_polled_clients;
416+ }
417+
418+ clients_loop: while (i > 0) {
419+ i -= 1;
420+ const client = daemon.clients.items[i];
421+ const revents = poll_fds.items[i + 3].revents;
422+
423+ if (revents & lib_posix.POLL.IN != 0) {
424+ const n = client.read_buf.read(client.socket_fd) catch |err| {
425+ if (err == error.WouldBlock) continue;
426+ std.log.debug(
427+ "client read err={s} fd={d}",
428+ .{ @errorName(err), client.socket_fd },
429+ );
430+ const last = daemon.closeClient(gpa, client, i, false);
431+ if (last) break :daemon_loop;
432+ continue;
433+ };
434+
435+ if (n == 0) {
436+ // Client closed connection
437+ const last = daemon.closeClient(gpa, client, i, false);
438+ if (last) break :daemon_loop;
439+ continue;
440+ }
441+
442+ while (client.read_buf.next()) |msg| {
443+ switch (msg.header.tag) {
444+ .Input => try daemon.handleInput(gpa, client, msg.payload),
445+ .Send => daemon.handleSend(gpa, msg.payload),
446+ .Output => try daemon.handleOutput(gpa, msg.payload, &vt_stream),
447+ .Init => try daemon.handleInit(gpa, client, pty_fd, &term, msg.payload),
448+ .Switch => try daemon.handleSwitch(gpa, msg.payload),
449+ .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload),
450+ .Detach => {
451+ daemon.handleDetach(gpa, client, i);
452+ break :clients_loop;
453+ },
454+ .DetachAll => {
455+ daemon.handleDetachAll(gpa);
456+ break :clients_loop;
457+ },
458+ .Kill => {
459+ break :daemon_loop;
460+ },
461+ .Info => try daemon.handleInfo(gpa, client),
462+ .LabelGet => try daemon.handleLabelGet(gpa, client),
463+ .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload),
464+ .LabelClear => try daemon.handleLabelClear(gpa, client),
465+ .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
466+ .Run => try daemon.handleRun(gpa, client, msg.payload),
467+ .Ack, .TaskComplete, .LabelData => {},
468+ .Write => try daemon.handleWrite(gpa, client, msg.payload),
469+ _ => std.log.warn(
470+ "ignoring unknown IPC tag={d}",
471+ .{@intFromEnum(msg.header.tag)},
472+ ),
473+ }
474+ }
475+ }
476+
477+ if (revents & lib_posix.POLL.OUT != 0) {
478+ // Flush pending output buffers
479+ const n = lib_posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
480+ if (err == error.WouldBlock) break :blk 0;
481+ // Error on write, close client
482+ const last = daemon.closeClient(gpa, client, i, false);
483+ if (last) break :daemon_loop;
484+ continue;
485+ };
486+
487+ if (n > 0) {
488+ client.write_buf.replaceRange(gpa, 0, n, &[_]u8{}) catch unreachable;
489+ }
490+
491+ if (client.write_buf.items.len == 0) {
492+ client.has_pending_output = false;
493+ }
494+ }
495+
496+ if (revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
497+ const last = daemon.closeClient(gpa, client, i, false);
498+ if (last) break :daemon_loop;
499+ }
500+ }
501+ }
502+}
503+
504+const ClientResult = struct {
505+ kind: enum {
506+ detach,
507+ switch_session,
508+ },
509+ session_name: ?[]const u8,
510+};
511+
512+/// Client represents each terminal that has connected to a session.
513+///
514+/// Multiple Clients can connect to a single session.
515+pub const Client = struct {
516+ alloc: std.mem.Allocator,
517+ socket_fd: i32,
518+ has_pending_output: bool = false,
519+ read_buf: ipc.SocketBuffer,
520+ write_buf: std.ArrayList(u8),
521+
522+ pub fn deinit(self: *Client) void {
523+ lib_posix.close(self.socket_fd);
524+ self.read_buf.deinit();
525+ self.write_buf.deinit(self.alloc);
526+ }
527+};
528+
529+/// Daemon is responsible for managing a zmx session.
530+///
531+/// It holds all the state for a running session. Instead of a single daemon for all sessions, we
532+/// create a daemon for every session. This has some benefits. The ipc communication between
533+/// session clients and the daemon doesn't need to be tagged with the session name. If a daemon
534+/// crashes for one session won't crash all the other sessions.
535+///
536+/// Conceptually it's also much simpler to reason about.
537+pub const Daemon = struct {
538+ cfg: *Cfg,
539+ session_name: []const u8,
540+ socket_path: []const u8,
541+ // === opt ===
542+ pty_write_buf: std.ArrayList(u8) = .empty,
543+ clients: std.ArrayList(*Client) = .empty,
544+ labels: std.StringHashMapUnmanaged([]u8) = .empty,
545+ // This control which client is the leader. The leader controls terminal state and
546+ // cols/rows of session.
547+ leader_client_fd: ?i32 = null,
548+ running: bool = true,
549+ pid: i32 = undefined,
550+ command: ?[]const []const u8 = null,
551+ cwd: []const u8 = "",
552+ has_pty_output: bool = false,
553+ has_had_client: bool = false,
554+ has_terminal_client: bool = false, // true only after a real attach (.Init received)
555+ created_at: u64, // unix timestamp (ns)
556+ is_task_mode: bool = false, // flag for when session is run as a task
557+ task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
558+ task_ended_at: ?u64 = null, // timestamp when task exited
559+ pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
560+ shell: []const u8 = "/bin/sh",
561+
562+ /// Create a Daemon. Caller is responsible for freeing all variables passed
563+ /// into the init fn.
564+ pub fn init(io: std.Io, cfg: *Cfg, sesh_name: []const u8, socket_path: []const u8) Daemon {
565+ return .{
566+ .cfg = cfg,
567+ .session_name = sesh_name,
568+ .socket_path = socket_path,
569+ .created_at = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()),
570+ };
571+ }
572+
573+ pub fn deinit(self: *Daemon, gpa: std.mem.Allocator) void {
574+ self.clients.deinit(gpa);
575+ var it = self.labels.iterator();
576+ while (it.next()) |entry| {
577+ gpa.free(entry.key_ptr.*);
578+ gpa.free(entry.value_ptr.*);
579+ }
580+ self.labels.deinit(gpa);
581+ self.pty_write_buf.deinit(gpa);
582+ gpa.free(self.socket_path);
583+ }
584+
585+ pub fn shutdown(self: *Daemon, gpa: std.mem.Allocator) void {
586+ std.log.info("shutting down daemon session={s}", .{self.session_name});
587+ self.running = false;
588+
589+ for (self.clients.items) |client| {
590+ client.deinit();
591+ gpa.destroy(client);
592+ }
593+ self.clients.clearRetainingCapacity();
594+ }
595+
596+ pub fn closeClient(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize, shutdown_on_last: bool) bool {
597+ const fd = client.socket_fd;
598+ // leader is disconnected, remove ref and let another client claim leader on input
599+ if (self.leader_client_fd == client.socket_fd) {
600+ std.log.info(
601+ "unsetting leader session={s} fd={d}",
602+ .{ self.session_name, client.socket_fd },
603+ );
604+ self.leader_client_fd = null;
605+ }
606+ client.deinit();
607+ gpa.destroy(client);
608+ _ = self.clients.orderedRemove(i);
609+ std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
610+ if (shutdown_on_last and self.clients.items.len == 0) {
611+ self.shutdown(gpa);
612+ return true;
613+ }
614+ return false;
615+ }
616+
617+ /// ensureSession will either create or re-use the daemon used for a session.
618+ /// It will spin up a unix socket, double-fork the process (so it survives
619+ /// the terminal dying), and automatically attach the client to the ipc unix
620+ /// socket.
621+ ///
622+ /// The return bool value indicates if the current process is the daemon
623+ /// or the client since they have different behaviors post-fork.
624+ ///
625+ /// E.g. If it's the client process then we need to connect to the unix socket
626+ /// and run the clientLoop. If it's the daemon then we need to bail since
627+ /// the daemonLoop is created inside this fn and when it returns that means
628+ /// the daemon stopped and needs to exit.
629+ pub fn ensureSession(self: *Daemon, io: std.Io) !bool {
630+ const sesh_name = self.session_name;
631+ std.log.info("ensure session session={s}", .{sesh_name});
632+ var dir = try std.Io.Dir.openDirAbsolute(io, self.cfg.socket_dir, .{});
633+ defer dir.close(io);
634+
635+ const exists = try socket.sessionExists(io, dir, sesh_name);
636+ // if daemon is gone then we flip this to true
637+ var should_create = !exists;
638+
639+ if (exists) {
640+ if (ipc.connectSession(self.socket_path)) |fd| {
641+ lib_posix.close(fd);
642+ if (self.command != null) {
643+ std.log.warn(
644+ "session already exists, ignoring command session={s}",
645+ .{sesh_name},
646+ );
647+ }
648+ } else |err| switch (err) {
649+ // Daemon is definitively gone: safe to replace.
650+ error.ConnectionRefused => {
651+ socket.cleanupStaleSocket(io, dir, sesh_name);
652+ should_create = true;
653+ },
654+ // Connect failed for an unusual reason. The check is only to
655+ // decide create-vs-attach; the socket file exists, so proceed
656+ // to attach rather than fail or orphan.
657+ else => {
658+ std.log.warn(
659+ "connect failed ({s}), proceeding to attach session={s}",
660+ .{ @errorName(err), sesh_name },
661+ );
662+ },
663+ }
664+ }
665+
666+ if (!should_create) {
667+ return false;
668+ }
669+
670+ return self.run(io, dir, sesh_name);
671+ }
672+
673+ fn run(self: *Daemon, io: std.Io, dir: std.Io.Dir, sesh_name: []const u8) !bool {
674+ std.log.info("creating session={s}", .{sesh_name});
675+ const server_sock_fd: lib_posix.socket_t = try socket.createSocket(self.socket_path);
676+ const log_fd = log.log_system.file.?.handle;
677+
678+ var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd };
679+ const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command);
680+ const pty_info = daemonize.daemonize(
681+ sesh_name,
682+ cmd,
683+ &keep_fds_open,
684+ ) catch |err| {
685+ switch (err) {
686+ error.IsClientProc => {
687+ // send a msg to the client that the session was created.
688+ var w_buf: [2048]u8 = undefined;
689+ var w = std.Io.File.stdout().writer(io, &w_buf);
690+ try w.interface.print("session \"{s}\" created\n", .{sesh_name});
691+ try w.interface.flush();
692+ lib_posix.close(server_sock_fd);
693+ return false;
694+ },
695+ else => {
696+ lib_posix.close(server_sock_fd);
697+ dir.deleteFile(io, self.session_name) catch {};
698+ return err;
699+ },
700+ }
701+ };
702+ // =======
703+ // WARNING: cannot use upstream allocator or io after this point since
704+ // we forked the process and there's a risk of a mutex (e.g. thread-safe
705+ // allocator) being locked by a thread prior to fork which can cause a
706+ // deadlock.
707+ // =======
708+
709+ self.pid = pty_info.pid;
710+
711+ var threaded: std.Io.Threaded = .init_single_threaded;
712+ defer threaded.deinit();
713+ const new_io = threaded.io();
714+
715+ { // re-initialize logs with the session name as the filename
716+ log.log_system.deinit();
717+ var log_buf: [4096]u8 = undefined;
718+ const session_log_name = try std.fmt.bufPrint(
719+ &log_buf,
720+ "{s}.log",
721+ .{sesh_name},
722+ );
723+ var fba_buf: [4096]u8 = undefined;
724+ var fba = std.heap.FixedBufferAllocator.init(&fba_buf);
725+ const session_log_path = try std.fs.path.join(
726+ fba.allocator(),
727+ &.{ self.cfg.log_dir, session_log_name },
728+ );
729+ const log_mode = std.Io.File.Permissions.fromMode(self.cfg.log_mode);
730+ log.log_system.init(new_io, session_log_path, log_mode) catch {};
731+ }
732+
733+ const gpa: std.mem.Allocator = blk: {
734+ if (builtin.mode == .Debug) {
735+ const GPA = std.heap.DebugAllocator(.{});
736+ const Static = struct {
737+ var gpa: GPA = .{};
738+ };
739+ break :blk Static.gpa.allocator();
740+ }
741+ break :blk std.heap.c_allocator;
742+ };
743+
744+ defer {
745+ // Close and unlink the listen socket BEFORE handleKill()'s
746+ // 500ms SIGHUP->SIGKILL grace sleep. Otherwise a `zmx run`
747+ // for the same name issued in that window will hang waiting
748+ // for a connect.
749+ lib_posix.close(server_sock_fd);
750+ std.log.info("deleting socket file session={s}", .{sesh_name});
751+ dir.deleteFile(new_io, sesh_name) catch |err| {
752+ std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
753+ };
754+ self.handleKill(gpa, new_io);
755+ self.deinit(gpa);
756+ lib_posix.close(pty_info.master_fd);
757+ _ = lib_posix.waitpid(self.pid, 0);
758+ }
759+
760+ try daemonLoop(self, gpa, new_io, server_sock_fd, pty_info.master_fd);
761+ std.log.info("daemon loop shutdown", .{});
762+ return true;
763+ }
764+
765+ fn setLeader(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
766+ std.log.info("setting new leader client_fd={d}", .{client.socket_fd});
767+ self.leader_client_fd = client.socket_fd;
768+ // Send a resize message to the client so it can send us back their window size
769+ // so we can resize the pty and ghostty state.
770+ try ipc.appendMessage(gpa, &client.write_buf, .Resize, "");
771+ client.has_pending_output = true;
772+ }
773+
774+ const PTY_WRITE_BUF_MAX = 256 * 1024;
775+
776+ /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT.
777+ /// Drops the payload if the buffer is over cap -- same failure mode as
778+ /// the old direct-write ptyWrite (drop on EAGAIN), just at a 64x higher
779+ /// threshold. Capping avoids OOM when the shell stops reading; dropping
780+ /// new (not old) bytes avoids tearing a partially-accepted sequence.
781+ fn queuePtyInput(self: *Daemon, gpa: std.mem.Allocator, data: []const u8) void {
782+ if (data.len == 0) return;
783+ if (self.pty_write_buf.items.len + data.len > PTY_WRITE_BUF_MAX) {
784+ std.log.warn(
785+ "pty input dropped {d} bytes (buffer full, shell not reading)",
786+ .{data.len},
787+ );
788+ return;
789+ }
790+ std.log.debug("buffering pty input data={x}", .{data});
791+ self.pty_write_buf.appendSlice(gpa, data) catch |err| {
792+ std.log.warn(
793+ "pty input dropped {d} bytes: {s}",
794+ .{ data.len, @errorName(err) },
795+ );
796+ };
797+ }
798+
799+ pub fn handleInput(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
800+ std.log.debug("buffering pty input data={x}", .{payload});
801+ // client is leader, send entire payload (ansi escape codes + text)
802+ if (self.leader_client_fd == client.socket_fd) {
803+ self.queuePtyInput(gpa, payload);
804+ return;
805+ }
806+
807+ // check if leader needs to be updated by detecting any user input
808+ if (util.isUserInput(payload)) {
809+ try self.setLeader(gpa, client);
810+ self.queuePtyInput(gpa, payload);
811+ }
812+ }
813+
814+ /// Queue input from `zmx send` without changing interactive client leadership.
815+ pub fn handleSend(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8) void {
816+ self.queuePtyInput(gpa, payload);
817+ }
818+
819+ pub fn handleSwitch(self: *Daemon, gpa: std.mem.Allocator, session_name: []const u8) !void {
820+ for (self.clients.items) |client| {
821+ if (self.leader_client_fd == client.socket_fd) {
822+ ipc.appendMessage(
823+ gpa,
824+ &client.write_buf,
825+ .Switch,
826+ session_name,
827+ ) catch |err| {
828+ std.log.warn(
829+ "failed to buffer terminal state for client err={s}",
830+ .{@errorName(err)},
831+ );
832+ };
833+ client.has_pending_output = true;
834+ return;
835+ }
836+ }
837+ return error.NoLeaderFound;
838+ }
839+
840+ pub fn handleInit(
841+ self: *Daemon,
842+ gpa: std.mem.Allocator,
843+ client: *Client,
844+ pty_fd: i32,
845+ term: *ghostty_vt.Terminal,
846+ payload: []const u8,
847+ ) !void {
848+ if (payload.len != @sizeOf(ipc.Resize)) return;
849+
850+ // Serialize terminal state BEFORE resize to capture correct cursor position.
851+ // Resizing triggers reflow which can move the cursor, and the shell's
852+ // SIGWINCH-triggered redraw will run after our snapshot is sent.
853+ // Only serialize on re-attach (has_had_client), not first attach, to avoid
854+ // interfering with shell initialization (DA1 queries, etc.)
855+ if (self.has_pty_output and self.has_had_client) {
856+ const cursor = &term.screens.active.cursor;
857+ std.log.debug(
858+ "cursor before serialize: x={d} y={d} pending_wrap={}",
859+ .{ cursor.x, cursor.y, cursor.pending_wrap },
860+ );
861+ if (util.serializeTerminalState(gpa, term)) |term_output| {
862+ std.log.debug("serialize terminal state", .{});
863+ // Rewrite OSC 133;A to include redraw=0 so the outer terminal
864+ // does not clear prompt lines on resize (issue #111).
865+ const restore_data = util.rewritePromptRedraw(gpa, term_output) orelse term_output;
866+ defer gpa.free(term_output);
867+ defer if (restore_data.ptr != term_output.ptr) gpa.free(restore_data);
868+ ipc.appendMessage(gpa, &client.write_buf, .Output, restore_data) catch |err| {
869+ std.log.warn(
870+ "failed to buffer terminal state for client err={s}",
871+ .{@errorName(err)},
872+ );
873+ };
874+ client.has_pending_output = true;
875+ }
876+ }
877+
878+ // no leader is set so set one
879+ if (self.leader_client_fd == null) {
880+ try self.setLeader(gpa, client);
881+ }
882+
883+ // only resize if leader
884+ if (self.leader_client_fd == client.socket_fd) {
885+ const resize = std.mem.bytesToValue(ipc.Resize, payload);
886+ var ws: cross.c.struct_winsize = .{
887+ .ws_row = resize.rows,
888+ .ws_col = resize.cols,
889+ .ws_xpixel = resize.xpixel,
890+ .ws_ypixel = resize.ypixel,
891+ };
892+ _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
893+ // Disable prompt_redraw before resize. The daemon's internal terminal
894+ // would otherwise clear prompt lines expecting the shell to redraw them,
895+ // but the shell's redraw goes to the PTY (forwarded to clients), not to
896+ // this daemon terminal. The clearing corrupts the daemon's snapshot state.
897+ const saved_prompt_redraw = term.flags.shell_redraws_prompt;
898+ term.flags.shell_redraws_prompt = .false;
899+ defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
900+ const opts = ghostty_vt.Terminal.Resize{
901+ .cols = resize.cols,
902+ .rows = resize.rows,
903+ };
904+ try term.resize(gpa, opts);
905+
906+ // Mark that we've had a client init, so subsequent clients get terminal state
907+ self.has_had_client = true;
908+ self.has_terminal_client = true;
909+
910+ std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
911+ }
912+ }
913+
914+ pub fn handleResize(
915+ self: *Daemon,
916+ gpa: std.mem.Allocator,
917+ client: *Client,
918+ pty_fd: i32,
919+ term: *ghostty_vt.Terminal,
920+ payload: []const u8,
921+ ) !void {
922+ if (payload.len != @sizeOf(ipc.Resize)) return;
923+ if (self.leader_client_fd == null) {
924+ try self.setLeader(gpa, client);
925+ }
926+ // only leader can resize
927+ if (self.leader_client_fd != client.socket_fd) return;
928+
929+ const resize = std.mem.bytesToValue(ipc.Resize, payload);
930+ var ws: cross.c.struct_winsize = .{
931+ .ws_row = resize.rows,
932+ .ws_col = resize.cols,
933+ .ws_xpixel = resize.xpixel,
934+ .ws_ypixel = resize.ypixel,
935+ };
936+ _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
937+ // Disable prompt_redraw before resize (same rationale as handleInit).
938+ const saved_prompt_redraw = term.flags.shell_redraws_prompt;
939+ term.flags.shell_redraws_prompt = .false;
940+ defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
941+ const opts = ghostty_vt.Terminal.Resize{
942+ .cols = resize.cols,
943+ .rows = resize.rows,
944+ };
945+ try term.resize(gpa, opts);
946+ std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
947+ }
948+
949+ pub fn handleDetach(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize) void {
950+ std.log.info("client detach session={s} fd={d}", .{ self.session_name, client.socket_fd });
951+ _ = self.closeClient(gpa, client, i, false);
952+ }
953+
954+ pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void {
955+ std.log.info("detach all clients={d}", .{self.clients.items.len});
956+ for (self.clients.items) |client_to_close| {
957+ client_to_close.deinit();
958+ gpa.destroy(client_to_close);
959+ }
960+ self.clients.clearRetainingCapacity();
961+ }
962+
963+ pub fn handleKill(self: *Daemon, gpa: std.mem.Allocator, io: std.Io) void {
964+ std.log.info("kill received session={s}", .{self.session_name});
965+ self.shutdown(gpa);
966+ // gracefully shutdown shell processes, shells tend to ignore SIGTERM so we send SIGHUP
967+ // instead
968+ // https://www.gnu.org/software/bash/manual/html_node/Signals.html
969+ // negative pid means kill process and children
970+ std.log.info("sending SIGHUP session={s} pid={d}", .{ self.session_name, self.pid });
971+ lib_posix.kill(-self.pid, lib_posix.SIG.HUP) catch |err| {
972+ std.log.warn("failed to send SIGHUP to pty child err={s}", .{@errorName(err)});
973+ };
974+ std.Io.sleep(io, std.Io.Duration.fromMilliseconds(500), .real) catch unreachable;
975+ lib_posix.kill(-self.pid, lib_posix.SIG.KILL) catch |err| {
976+ std.log.warn("failed to send SIGKILL to pty child err={s}", .{@errorName(err)});
977+ };
978+ }
979+
980+ pub fn handleInfo(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
981+ // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd
982+ // tail bytes (daemon stack contents) to clients.
983+ var info = std.mem.zeroes(ipc.Info);
984+ info.clients_len = self.clients.items.len - 1;
985+ info.pid = self.pid;
986+ info.created_at = self.created_at;
987+ info.task_ended_at = self.task_ended_at orelse 0;
988+ info.task_exit_code = self.task_exit_code orelse 0;
989+
990+ // Build command string from args, re-quoting args that contain
991+ // shell-special characters so the displayed command is copy-pasteable.
992+ const cur_cmd = self.command;
993+ if (cur_cmd) |args| {
994+ for (args, 0..) |arg, i| {
995+ const quoted = if (util.shellNeedsQuoting(arg))
996+ util.shellQuote(gpa, arg) catch null
997+ else
998+ null;
999+ defer if (quoted) |q| gpa.free(q);
1000+ const src = quoted orelse arg;
1001+
1002+ const need = src.len + @as(usize, if (i > 0) 1 else 0);
1003+ if (info.cmd_len + need > ipc.MAX_CMD_LEN) {
1004+ const ellipsis = "...";
1005+ if (info.cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) {
1006+ @memcpy(info.cmd[info.cmd_len..][0..ellipsis.len], ellipsis);
1007+ info.cmd_len += ellipsis.len;
1008+ }
1009+ break;
1010+ }
1011+
1012+ if (i > 0) {
1013+ info.cmd[info.cmd_len] = ' ';
1014+ info.cmd_len += 1;
1015+ }
1016+ @memcpy(info.cmd[info.cmd_len..][0..src.len], src);
1017+ info.cmd_len += @intCast(src.len);
1018+ }
1019+ }
1020+
1021+ info.cwd_len = @intCast(@min(self.cwd.len, ipc.MAX_CWD_LEN));
1022+ @memcpy(info.cwd[0..info.cwd_len], self.cwd[0..info.cwd_len]);
1023+
1024+ try ipc.appendMessage(gpa, &client.write_buf, .Info, std.mem.asBytes(&info));
1025+ client.has_pending_output = true;
1026+ }
1027+
1028+ pub fn handleHistory(
1029+ _: *Daemon,
1030+ gpa: std.mem.Allocator,
1031+ client: *Client,
1032+ term: *ghostty_vt.Terminal,
1033+ payload: []const u8,
1034+ ) !void {
1035+ const format: util.HistoryFormat = if (payload.len > 0)
1036+ @enumFromInt(payload[0])
1037+ else
1038+ .plain;
1039+ if (util.serializeTerminal(gpa, term, format)) |output| {
1040+ defer gpa.free(output);
1041+ try ipc.appendMessage(gpa, &client.write_buf, .History, output);
1042+ client.has_pending_output = true;
1043+ } else {
1044+ try ipc.appendMessage(gpa, &client.write_buf, .History, "");
1045+ client.has_pending_output = true;
1046+ }
1047+ }
1048+
1049+ pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
1050+ // Reset task tracking so the new command's exit marker is detected.
1051+ // Without this, a second `zmx run` on the same session is ignored
1052+ // because task_exit_code is still set from the first run.
1053+ self.task_exit_code = null;
1054+ self.task_ended_at = null;
1055+ self.is_task_mode = true;
1056+
1057+ if (payload.len == 0) return;
1058+
1059+ const cmd = payload;
1060+
1061+ // Chain the exit marker with `;` on the same line. `$?` captures the
1062+ // exit code of the command (not the `;`). The sole exception is when
1063+ // the command contains a heredoc (`<<`), the delimiter must be alone
1064+ // on its line, so the marker goes on the next line instead.
1065+ const single_line_marker = "; echo ZMX_TASK_COMPLETED:$?\r";
1066+ const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
1067+ const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
1068+
1069+ if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
1070+ self.queuePtyInput(gpa, cmd[0 .. cmd.len - 1]);
1071+ } else {
1072+ self.queuePtyInput(gpa, cmd);
1073+ }
1074+ self.queuePtyInput(gpa, if (uses_heredoc) heredoc_marker else single_line_marker);
1075+
1076+ try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1077+ client.has_pending_output = true;
1078+ self.has_had_client = true;
1079+ std.log.debug("run command len={d}", .{payload.len});
1080+ }
1081+
1082+ pub fn handleOutput(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8, vt_stream: anytype) !void {
1083+ vt_stream.nextSlice(payload);
1084+ self.has_pty_output = true;
1085+ for (self.clients.items) |client| {
1086+ try ipc.appendMessage(gpa, &client.write_buf, .Output, payload);
1087+ client.has_pending_output = true;
1088+ }
1089+ if (self.clients.items.len > 0) {
1090+ lib_posix.kill(self.pid, lib_posix.SIG.WINCH) catch |err| {
1091+ std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
1092+ };
1093+ }
1094+ }
1095+
1096+ pub fn handleWrite(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
1097+ // Wire format: [u32 path len][path bytes][file content]
1098+ if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
1099+ const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
1100+ if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
1101+ const file_path = payload[@sizeOf(u32)..][0..path_len];
1102+ const file_content = payload[@sizeOf(u32) + path_len ..];
1103+
1104+ // Inject file creation through the PTY so it works over SSH.
1105+ // Base64-encode content and pipe through printf | base64 -d > file.
1106+ // Chunk large files to stay under command-line length limits.
1107+ // 48000 is divisible by 3 (clean base64 boundaries) and encodes
1108+ // to ~64KB, well under typical ARG_MAX.
1109+ const chunk_size = 48000;
1110+ var offset: usize = 0;
1111+ var is_first = true;
1112+
1113+ while (offset < file_content.len or is_first) {
1114+ const end = @min(offset + chunk_size, file_content.len);
1115+ const chunk = file_content[offset..end];
1116+
1117+ const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
1118+ const encoded = try gpa.alloc(u8, encoded_len);
1119+ defer gpa.free(encoded);
1120+ _ = std.base64.standard.Encoder.encode(encoded, chunk);
1121+
1122+ self.queuePtyInput(gpa, "printf '%s' '");
1123+ self.queuePtyInput(gpa, encoded);
1124+ if (is_first) {
1125+ self.queuePtyInput(gpa, "' | base64 -d > '");
1126+ } else {
1127+ self.queuePtyInput(gpa, "' | base64 -d >> '");
1128+ }
1129+ self.queuePtyInput(gpa, file_path);
1130+ self.queuePtyInput(gpa, "'");
1131+ self.queuePtyInput(gpa, "\r");
1132+
1133+ offset = end;
1134+ is_first = false;
1135+ }
1136+
1137+ try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1138+ client.has_pending_output = true;
1139+ self.has_had_client = true;
1140+ std.log.debug(
1141+ "write command len={d} file_path={s}",
1142+ .{ file_content.len, file_path },
1143+ );
1144+ }
1145+
1146+ fn handleLabelGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
1147+ const out = try label.labelsToU8(gpa, self.labels);
1148+ defer gpa.free(out);
1149+ try ipc.appendMessage(gpa, &client.write_buf, .LabelData, out);
1150+ client.has_pending_output = true;
1151+ }
1152+
1153+ fn handleLabelSet(self: *Daemon, gpa: std.mem.Allocator, client: *Client, labels: []const u8) !void {
1154+ std.log.info("handle label set payload={s}", .{labels});
1155+
1156+ var kvs = label.LabelIterator.init(labels);
1157+ while (kvs.next()) |kv| {
1158+ if (kv.value.len == 0) {
1159+ if (self.labels.fetchRemove(kv.key)) |existing| {
1160+ gpa.free(existing.key);
1161+ gpa.free(existing.value);
1162+ }
1163+ continue;
1164+ }
1165+
1166+ const owned_key = try gpa.dupe(u8, kv.key);
1167+ errdefer gpa.free(owned_key);
1168+ const owned_value = try gpa.dupe(u8, kv.value);
1169+ errdefer gpa.free(owned_value);
1170+ if (try self.labels.fetchPut(gpa, owned_key, owned_value)) |existing| {
1171+ // fetchPut does NOT replace the key in the map, the old
1172+ // key pointer stays. So free the new (unused) key and the
1173+ // old value.
1174+ gpa.free(owned_key);
1175+ gpa.free(existing.value);
1176+ }
1177+ }
1178+
1179+ try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1180+ client.has_pending_output = true;
1181+ }
1182+
1183+ fn handleLabelClear(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
1184+ var it = self.labels.iterator();
1185+ while (it.next()) |entry| {
1186+ gpa.free(entry.key_ptr.*);
1187+ gpa.free(entry.value_ptr.*);
1188+ }
1189+ self.labels.clearRetainingCapacity();
1190+ try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1191+ client.has_pending_output = true;
1192+ }
1193+};
1194+
1195+test "send queues PTY input without changing leader" {
1196+ const alloc = std.testing.allocator;
1197+ var daemon = Daemon{
1198+ .cfg = undefined,
1199+ .clients = .empty,
1200+ .leader_client_fd = 42,
1201+ .session_name = "test",
1202+ .socket_path = "",
1203+ .running = true,
1204+ .pid = 0,
1205+ .created_at = 0,
1206+ };
1207+ defer daemon.pty_write_buf.deinit(alloc);
1208+
1209+ daemon.handleSend(alloc, "hello");
1210+
1211+ try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
1212+ try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
1213+}
+139,
-1711
1@@ -9,88 +9,20 @@ const cross = @import("cross.zig");
2 const socket = @import("socket.zig");
3 const label = @import("label.zig");
4 const lib_posix = @import("posix.zig");
5-
6-pub const version = build_options.version;
7-pub const ghostty_version = build_options.ghostty_version;
8-
9-var log_system = log.LogSystem{};
10+const signal = @import("signal.zig");
11+const Cfg = @import("cfg.zig");
12+const loop = @import("loop.zig");
13+const Client = loop.Client;
14+const Daemon = loop.Daemon;
15+const version = build_options.version;
16+const ghostty_version = build_options.ghostty_version;
17
18 pub const std_options: std.Options = .{
19- .logFn = zmxLogFn,
20+ .logFn = log.zmxLogFn,
21 .log_level = .debug,
22 };
23
24-fn zmxLogFn(
25- comptime level: std.log.Level,
26- comptime scope: anytype,
27- comptime format: []const u8,
28- args: anytype,
29-) void {
30- log_system.log(level, scope, format, args) catch {};
31-}
32-
33-/// Self-pipe woken by signal handlers. std.posix.poll loops on .INTR internally
34-/// (PollError has no Interrupted member), so a signal that lands during poll()
35-/// never surfaces; the handler writes a byte here and poll() wakes on POLLIN.
36-var sig_pipe: [2]lib_posix.fd_t = .{ -1, -1 };
37-
38-// https://github.com/ziglang/zig/blob/738d2be9d6b6ef3ff3559130c05159ef53336224/lib/std/posix.zig#L3505
39-const O_NONBLOCK: usize = 1 << @bitOffsetOf(lib_posix.O, "NONBLOCK");
40-
41-const SessionMatch = struct {
42- name: []const u8,
43- is_prefix: bool,
44-
45- fn matches(self: SessionMatch, session_name: []const u8) bool {
46- if (self.is_prefix) return std.mem.startsWith(u8, session_name, self.name);
47- return std.mem.eql(u8, session_name, self.name);
48- }
49-};
50-
51-fn resolveSessionOrEnv(alloc: std.mem.Allocator, io: std.Io, session_name: ?[]const u8) ![]const u8 {
52- const sesh_env = socket.getSeshNameFromEnv();
53- const raw = if (session_name) |name|
54- if (std.mem.eql(u8, name, ".")) blk: {
55- if (sesh_env.len > 0) break :blk sesh_env;
56- var buf: [4096]u8 = undefined;
57- var w = std.Io.File.stderr().writer(io, &buf);
58- w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
59- w.interface.flush() catch {};
60- return error.SessionNameRequired;
61- } else name
62- else if (sesh_env.len > 0)
63- sesh_env
64- else {
65- return error.SessionNameRequired;
66- };
67- return socket.getSeshName(alloc, raw);
68-}
69-
70-fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
71- if (raw.len > 0 and raw[raw.len - 1] == '*') {
72- const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]);
73- return .{ .name = name, .is_prefix = true };
74- }
75- const name = try socket.getSeshName(alloc, raw);
76- return .{ .name = name, .is_prefix = false };
77-}
78-
79-fn openSignalPipe() !void {
80- sig_pipe = try lib_posix.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true });
81-}
82-
83-fn drainSignalPipe() void {
84- var b: [16]u8 = undefined;
85- while (true) {
86- const n = lib_posix.read(sig_pipe[0], &b) catch return;
87- if (n == 0) return;
88- }
89-}
90-
91-fn detectHelp(arg: []const u8) bool {
92- return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
93-}
94-
95+/// This is the entry point for the CLI.
96 pub fn main(init: std.process.Init) !void {
97 const gpa = init.gpa;
98 const io = init.io;
99@@ -99,7 +31,7 @@ pub fn main(init: std.process.Init) !void {
100 // disappears between probe and send would otherwise kill us before
101 // write() can return BrokenPipe. Inherited across fork, so this also
102 // covers the daemon.
103- ignoreSigpipe();
104+ signal.ignoreSigpipe();
105
106 var args = init.minimal.args.iterate();
107 defer args.deinit();
108@@ -111,8 +43,8 @@ pub fn main(init: std.process.Init) !void {
109 const log_path = try std.fs.path.join(gpa, &.{ cfg.log_dir, "zmx.log" });
110 defer gpa.free(log_path);
111 const log_mode = std.Io.File.Permissions.fromMode(cfg.log_mode);
112- try log_system.init(gpa, io, log_path, log_mode);
113- defer log_system.deinit();
114+ try log.log_system.init(io, log_path, log_mode);
115+ defer log.log_system.deinit();
116
117 const shell_env = init.environ_map.get("SHELL") orelse "/bin/sh";
118
119@@ -134,14 +66,14 @@ pub fn main(init: std.process.Init) !void {
120 } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
121 const sesh_name = args.next() orelse return error.SessionNameRequired;
122 if (detectHelp(sesh_name)) return help(io);
123- const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
124+ const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
125 defer gpa.free(sesh);
126 const single_kv = args.next() orelse "";
127 return labelGet(gpa, io, &cfg, sesh, single_kv);
128 } else if (std.mem.eql(u8, cmd, "set")) {
129 const sesh_name = args.next() orelse return error.SessionNameRequired;
130 if (detectHelp(sesh_name)) return help(io);
131- const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
132+ const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
133 defer gpa.free(sesh);
134
135 var kvs = std.ArrayList(u8).empty;
136@@ -156,7 +88,7 @@ pub fn main(init: std.process.Init) !void {
137 } else if (std.mem.eql(u8, cmd, "clear")) {
138 const sesh_name = args.next() orelse return error.SessionNameRequired;
139 if (detectHelp(sesh_name)) return help(io);
140- const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
141+ const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
142 defer gpa.free(sesh);
143 return labelClear(gpa, io, &cfg, sesh);
144 } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
145@@ -198,7 +130,6 @@ pub fn main(init: std.process.Init) !void {
146 try command_args.append(gpa, arg);
147 }
148
149- const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
150 var command: ?[][]const u8 = null;
151 if (command_args.items.len > 0) {
152 command = command_args.items;
153@@ -210,27 +141,16 @@ pub fn main(init: std.process.Init) !void {
154
155 const sesh = try socket.getSeshName(gpa, session_name);
156 defer gpa.free(sesh);
157- var daemon = Daemon{
158- .io = io,
159- .running = true,
160- .cfg = &cfg,
161- .alloc = std.heap.c_allocator,
162- .clients = clients,
163- .session_name = sesh,
164- .socket_path = undefined,
165- .pid = undefined,
166- .command = command,
167- .cwd = cwd,
168- .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
169- .leader_client_fd = null,
170- .shell = shell_env,
171- };
172- daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
173- error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
174+ const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
175+ error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
176 error.OutOfMemory => return err,
177 };
178+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
179+ daemon.command = command;
180+ daemon.cwd = cwd;
181+ daemon.shell = shell_env;
182 std.log.info("socket path={s}", .{daemon.socket_path});
183- return attach(&daemon);
184+ return attach(gpa, io, &daemon);
185 } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
186 const session_name = args.next() orelse "";
187 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
188@@ -247,7 +167,6 @@ pub fn main(init: std.process.Init) !void {
189 try cmd_args_raw.append(gpa, arg);
190 }
191 }
192- const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
193
194 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
195 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
196@@ -255,28 +174,17 @@ pub fn main(init: std.process.Init) !void {
197
198 const sesh = try socket.getSeshName(gpa, session_name);
199 defer gpa.free(sesh);
200- var daemon = Daemon{
201- .io = io,
202- .running = true,
203- .cfg = &cfg,
204- .alloc = std.heap.c_allocator,
205- .clients = clients,
206- .session_name = sesh,
207- .socket_path = undefined,
208- .pid = undefined,
209- .command = null,
210- .cwd = cwd,
211- .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
212- .is_task_mode = true,
213- .leader_client_fd = null,
214- .shell = shell_env,
215- };
216- daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
217- error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
218+ const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
219+ error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
220 error.OutOfMemory => return err,
221 };
222+ defer gpa.free(socket_path);
223+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
224+ daemon.cwd = cwd;
225+ daemon.is_task_mode = true;
226+ daemon.shell = shell_env;
227 std.log.info("socket path={s}", .{daemon.socket_path});
228- return run(&daemon, detached, cmd_args_raw.items);
229+ return run(gpa, io, &daemon, detached, cmd_args_raw.items);
230 } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
231 const session_name = args.next() orelse "";
232 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
233@@ -322,7 +230,7 @@ pub fn main(init: std.process.Init) !void {
234 var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
235 const stderr = &stderr_writer.interface;
236
237- var matchers: std.ArrayList(SessionMatch) = .empty;
238+ var matchers: std.ArrayList(socket.SessionMatch) = .empty;
239 defer {
240 for (matchers.items) |m| {
241 gpa.free(m.name);
242@@ -338,7 +246,7 @@ pub fn main(init: std.process.Init) !void {
243 force = true;
244 continue;
245 }
246- const m = try parseSessionArg(gpa, session_name);
247+ const m = try socket.parseSessionArg(gpa, session_name);
248 try matchers.append(gpa, m);
249 }
250 if (matchers.items.len == 0) {
251@@ -369,7 +277,7 @@ pub fn main(init: std.process.Init) !void {
252 }
253 }
254 } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
255- var matchers: std.ArrayList(SessionMatch) = .empty;
256+ var matchers: std.ArrayList(socket.SessionMatch) = .empty;
257 defer {
258 for (matchers.items) |m| {
259 gpa.free(m.name);
260@@ -380,7 +288,7 @@ pub fn main(init: std.process.Init) !void {
261 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
262 return help(io);
263 }
264- const m = try parseSessionArg(gpa, session_name);
265+ const m = try socket.parseSessionArg(gpa, session_name);
266 try matchers.append(gpa, m);
267 }
268 if (matchers.items.len == 0) {
269@@ -388,7 +296,7 @@ pub fn main(init: std.process.Init) !void {
270 }
271 return wait(gpa, io, &cfg, matchers);
272 } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
273- var matchers: std.ArrayList(SessionMatch) = .empty;
274+ var matchers: std.ArrayList(socket.SessionMatch) = .empty;
275 defer {
276 for (matchers.items) |m| {
277 gpa.free(m.name);
278@@ -399,7 +307,7 @@ pub fn main(init: std.process.Init) !void {
279 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
280 return help(io);
281 }
282- const m = try parseSessionArg(gpa, session_name);
283+ const m = try socket.parseSessionArg(gpa, session_name);
284 try matchers.append(gpa, m);
285 }
286 if (matchers.items.len == 0) {
287@@ -479,962 +387,23 @@ pub fn main(init: std.process.Init) !void {
288 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
289 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
290 const cwd = cwd_buf[0..cwd_len];
291- const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
292 const sesh = try socket.getSeshName(gpa, session_name);
293 defer gpa.free(sesh);
294- var daemon = Daemon{
295- .io = io,
296- .running = true,
297- .cfg = &cfg,
298- .alloc = std.heap.c_allocator,
299- .clients = clients,
300- .session_name = sesh,
301- .socket_path = undefined,
302- .pid = undefined,
303- .command = null,
304- .cwd = cwd,
305- .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
306- .is_task_mode = true,
307- .leader_client_fd = null,
308- .shell = shell_env,
309- };
310- daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
311- error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
312+ const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
313+ error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
314 error.OutOfMemory => return err,
315 };
316+ var daemon = Daemon.init(io, &cfg, sesh, socket_path);
317+ daemon.is_task_mode = true;
318+ daemon.cwd = cwd;
319+ daemon.shell = shell_env;
320 std.log.info("socket path={s}", .{daemon.socket_path});
321- try writeFile(&daemon, file_path);
322+ try writeFile(gpa, io, &daemon, file_path);
323 } else {
324 return help(io);
325 }
326 }
327
328-/// Client represents each terminal that has connected to a session.
329-///
330-/// Multiple Clients can connect to a single session.
331-const Client = struct {
332- alloc: std.mem.Allocator,
333- socket_fd: i32,
334- has_pending_output: bool = false,
335- read_buf: ipc.SocketBuffer,
336- write_buf: std.ArrayList(u8),
337-
338- pub fn deinit(self: *Client) void {
339- lib_posix.close(self.socket_fd);
340- self.read_buf.deinit();
341- self.write_buf.deinit(self.alloc);
342- }
343-};
344-
345-/// Cfg is zmx's configuration container.
346-///
347-/// The purpose of this container is to hold anything that can be modified by the user.
348-const Cfg = struct {
349- socket_dir: []const u8,
350- log_dir: []const u8,
351- max_scrollback: usize = 10_000_000,
352- dir_mode: u32 = 0o750,
353- log_mode: u32 = 0o640,
354-
355- pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
356- const socket_dir = try socketDir(alloc);
357- errdefer alloc.free(socket_dir);
358- const log_dir = try logDir(alloc);
359- errdefer alloc.free(log_dir);
360-
361- const dir_mode = if (lib_posix.getenv("ZMX_DIR_MODE")) |m|
362- std.fmt.parseInt(u32, m, 8) catch 0o750
363- else
364- 0o750;
365-
366- const log_mode = if (lib_posix.getenv("ZMX_LOG_MODE")) |m|
367- std.fmt.parseInt(u32, m, 8) catch 0o640
368- else
369- 0o640;
370-
371- var cfg = Cfg{
372- .socket_dir = socket_dir,
373- .log_dir = log_dir,
374- .dir_mode = dir_mode,
375- .log_mode = log_mode,
376- };
377-
378- try cfg.mkdir(io);
379-
380- return cfg;
381- }
382-
383- fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
384- const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
385- const uid = lib_posix.getuid();
386-
387- const socket_dir: []const u8 = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
388- try alloc.dupe(u8, zmxdir)
389- else if (lib_posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
390- try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
391- else
392- try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
393-
394- return socket_dir;
395- }
396-
397- fn logDir(alloc: std.mem.Allocator) ![]const u8 {
398- const log_dir = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
399- try std.fmt.allocPrint(alloc, "{s}/logs", .{zmxdir})
400- else if (lib_posix.getenv("XDG_STATE_HOME")) |xdg_state_home|
401- try std.fmt.allocPrint(alloc, "{s}/zmx/logs", .{xdg_state_home})
402- else if (lib_posix.getenv("HOME")) |home_dir|
403- try std.fmt.allocPrint(alloc, "{s}/.local/state/zmx/logs", .{home_dir})
404- else fallback: {
405- // This is the last resort: falling back to /tmp/$UID if HOME is unset.
406- const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
407- const uid = lib_posix.getuid();
408- break :fallback try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
409- };
410-
411- return log_dir;
412- }
413-
414- pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
415- if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
416- if (self.log_dir.len > 0) alloc.free(self.log_dir);
417- }
418-
419- pub fn mkdir(self: *Cfg, io: std.Io) !void {
420- const sock_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
421- try mkdirAll(io, self.socket_dir, sock_perms);
422- const log_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
423- try mkdirAll(io, self.log_dir, log_perms);
424- }
425-
426- fn mkdirAll(io: std.Io, sub_dir_path: []const u8, permissions: std.Io.Dir.Permissions) !void {
427- var it = std.fs.path.componentIterator(sub_dir_path);
428- var component = it.last() orelse return error.BadPathName;
429- while (true) {
430- std.Io.Dir.createDirAbsolute(io, component.path, permissions) catch |err| switch (err) {
431- error.PathAlreadyExists => {},
432- error.FileNotFound => |e| {
433- component = it.previous() orelse return e;
434- continue;
435- },
436- else => |e| return e,
437- };
438- component = it.next() orelse return;
439- }
440- }
441-};
442-
443-test "Cfg.init uses default modes when env vars are not set" {
444- const alloc = std.testing.allocator;
445-
446- // Ensure they are not set
447- _ = cross.c.unsetenv("ZMX_DIR_MODE");
448- _ = cross.c.unsetenv("ZMX_LOG_MODE");
449-
450- var cfg = try Cfg.init(alloc, std.testing.io);
451- defer cfg.deinit(alloc);
452-
453- try std.testing.expectEqual(@as(u32, 0o750), cfg.dir_mode);
454- try std.testing.expectEqual(@as(u32, 0o640), cfg.log_mode);
455-}
456-
457-test "Cfg.init uses custom modes from env vars" {
458- const alloc = std.testing.allocator;
459-
460- // Set custom octal values
461- _ = cross.c.setenv("ZMX_DIR_MODE", "770", 1);
462- _ = cross.c.setenv("ZMX_LOG_MODE", "660", 1);
463- defer {
464- _ = cross.c.unsetenv("ZMX_DIR_MODE");
465- _ = cross.c.unsetenv("ZMX_LOG_MODE");
466- }
467-
468- var cfg = try Cfg.init(alloc, std.testing.io);
469- defer cfg.deinit(alloc);
470-
471- try std.testing.expectEqual(@as(u32, 0o770), cfg.dir_mode);
472- try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
473-}
474-
475-/// Daemon is responsible for managing a zmx session.
476-///
477-/// It holds all the state for a running session. Instead of a single daemon for all sessions, we
478-/// create a daemon for every session. This has some benefits. The ipc communication between
479-/// session clients and the daemon doesn't need to be tagged with the session name. If a daemon
480-/// crashes for one session won't crash all the other sessions.
481-///
482-/// Conceptually it's also much simpler to reason about.
483-const Daemon = struct {
484- io: std.Io,
485- cfg: *Cfg,
486- alloc: std.mem.Allocator,
487- clients: std.ArrayList(*Client),
488- labels: std.StringHashMapUnmanaged([]u8) = .empty,
489- // This control which client is the leader. The leader controls terminal state and
490- // cols/rows of session.
491- leader_client_fd: ?i32,
492- session_name: []const u8,
493- socket_path: []const u8,
494- running: bool,
495- pid: i32,
496- command: ?[]const []const u8 = null,
497- cwd: []const u8 = "",
498- has_pty_output: bool = false,
499- has_had_client: bool = false,
500- has_terminal_client: bool = false, // true only after a real attach (.Init received)
501- created_at: u64, // unix timestamp (ns)
502- is_task_mode: bool = false, // flag for when session is run as a task
503- task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
504- task_ended_at: ?u64 = null, // timestamp when task exited
505- pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
506- pty_write_buf: std.ArrayList(u8) = .empty,
507- shell: []const u8 = "/bin/sh",
508-
509- const EnsureSessionResult = struct {
510- created: bool,
511- is_daemon: bool,
512- };
513-
514- pub fn deinit(self: *Daemon) void {
515- self.clients.deinit(self.alloc);
516- var it = self.labels.iterator();
517- while (it.next()) |entry| {
518- self.alloc.free(entry.key_ptr.*);
519- self.alloc.free(entry.value_ptr.*);
520- }
521- self.labels.deinit(self.alloc);
522- self.pty_write_buf.deinit(self.alloc);
523- self.alloc.free(self.socket_path);
524- }
525-
526- fn handleLabelGet(self: *Daemon, client: *Client) !void {
527- const out = try label.labelsToU8(self.alloc, self.labels);
528- defer self.alloc.free(out);
529- try ipc.appendMessage(self.alloc, &client.write_buf, .LabelData, out);
530- client.has_pending_output = true;
531- }
532-
533- fn handleLabelSet(self: *Daemon, client: *Client, labels: []const u8) !void {
534- std.log.info("handle label set payload={s}", .{labels});
535-
536- var kvs = label.LabelIterator.init(labels);
537- while (kvs.next()) |kv| {
538- if (kv.value.len == 0) {
539- if (self.labels.fetchRemove(kv.key)) |existing| {
540- self.alloc.free(existing.key);
541- self.alloc.free(existing.value);
542- }
543- continue;
544- }
545-
546- const owned_key = try self.alloc.dupe(u8, kv.key);
547- errdefer self.alloc.free(owned_key);
548- const owned_value = try self.alloc.dupe(u8, kv.value);
549- errdefer self.alloc.free(owned_value);
550- if (try self.labels.fetchPut(self.alloc, owned_key, owned_value)) |existing| {
551- // fetchPut does NOT replace the key in the map, the old
552- // key pointer stays. So free the new (unused) key and the
553- // old value.
554- self.alloc.free(owned_key);
555- self.alloc.free(existing.value);
556- }
557- }
558-
559- try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
560- client.has_pending_output = true;
561- }
562-
563- fn handleLabelClear(self: *Daemon, client: *Client) !void {
564- var it = self.labels.iterator();
565- while (it.next()) |entry| {
566- self.alloc.free(entry.key_ptr.*);
567- self.alloc.free(entry.value_ptr.*);
568- }
569- self.labels.clearRetainingCapacity();
570- try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
571- client.has_pending_output = true;
572- }
573-
574- pub fn shutdown(self: *Daemon) void {
575- std.log.info("shutting down daemon session={s}", .{self.session_name});
576- self.running = false;
577-
578- for (self.clients.items) |client| {
579- client.deinit();
580- self.alloc.destroy(client);
581- }
582- self.clients.clearRetainingCapacity();
583- }
584-
585- pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool {
586- const fd = client.socket_fd;
587- // leader is disconnected, remove ref and let another client claim leader on input
588- if (self.leader_client_fd == client.socket_fd) {
589- std.log.info(
590- "unsetting leader session={s} fd={d}",
591- .{ self.session_name, client.socket_fd },
592- );
593- self.leader_client_fd = null;
594- }
595- client.deinit();
596- self.alloc.destroy(client);
597- _ = self.clients.orderedRemove(i);
598- std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
599- if (shutdown_on_last and self.clients.items.len == 0) {
600- self.shutdown();
601- return true;
602- }
603- return false;
604- }
605-
606- fn setLeader(self: *Daemon, client: *Client) !void {
607- std.log.info("setting new leader client_fd={d}", .{client.socket_fd});
608- self.leader_client_fd = client.socket_fd;
609- // Send a resize message to the client so it can send us back their window size
610- // so we can resize the pty and ghostty state.
611- try ipc.appendMessage(self.alloc, &client.write_buf, .Resize, "");
612- client.has_pending_output = true;
613- }
614-
615- /// Runs in the forked child. Either execs or returns an error (caller
616- /// must exit on error -- returning would fall through to parent code).
617- fn execChild(self: *Daemon) !noreturn {
618- const alloc = std.heap.c_allocator;
619-
620- // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
621- // exec. Restore the default so the shell and its children behave
622- // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
623- const dfl: lib_posix.Sigaction = .{
624- .handler = .{ .handler = lib_posix.SIG.DFL },
625- .mask = lib_posix.sigemptyset(),
626- .flags = 0,
627- };
628- lib_posix.sigaction(lib_posix.SIG.PIPE, &dfl, null);
629-
630- const session_env = try std.fmt.allocPrintSentinel(
631- alloc,
632- "ZMX_SESSION={s}",
633- .{self.session_name},
634- 0,
635- );
636- _ = cross.c.putenv(session_env.ptr);
637-
638- if (self.command) |cmd_args| {
639- const argv = try alloc.allocSentinel(?[*:0]const u8, cmd_args.len, null);
640- for (cmd_args, 0..) |arg, i| {
641- argv[i] = try alloc.dupeZ(u8, arg);
642- }
643- const err = lib_posix.execvpeZ(argv[0].?, argv.ptr, std.c.environ);
644- std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
645- lib_posix.exit(1);
646- }
647-
648- var buf: [256]u8 = undefined;
649- const z = try std.fmt.bufPrintZ(&buf, "{s}", .{self.shell});
650- const shell: [:0]const u8 = if (self.is_task_mode) "bash" else z;
651- // Use "-shellname" as argv[0] to signal login shell (traditional method)
652- const login_shell = try std.fmt.allocPrintSentinel(
653- alloc,
654- "-{s}",
655- .{std.fs.path.basename(shell)},
656- 0,
657- );
658- const argv = [_:null]?[*:0]const u8{ login_shell, null };
659- const err = lib_posix.execvpeZ(shell, &argv, std.c.environ);
660- std.log.err("execvpe failed: shell={s} err={s}", .{ shell, @errorName(err) });
661- lib_posix.exit(1);
662- }
663-
664- /// spawnPty runs forkpty() and executes the shell or shell command the user provides.
665- fn spawnPty(self: *Daemon) !c_int {
666- const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
667- var ws: cross.c.struct_winsize = .{
668- .ws_row = size.rows,
669- .ws_col = size.cols,
670- .ws_xpixel = size.xpixel,
671- .ws_ypixel = size.ypixel,
672- };
673-
674- var master_fd: c_int = undefined;
675- const pid = cross.forkpty(&master_fd, null, null, &ws);
676- if (pid < 0) {
677- return error.ForkPtyFailed;
678- }
679-
680- if (pid == 0) { // child pid code path
681- // In the forked child, ANY error must exit rather than propagate:
682- // a returned error falls through to the parent code path below,
683- // running a second daemon on the same socket (or worse, hitting
684- // errdefers that delete the parent's socket file).
685- execChild(self) catch |err| {
686- std.log.err("child setup failed: {s}", .{@errorName(err)});
687- lib_posix.exit(1);
688- };
689- unreachable; // execChild either execs or exits, never returns ok
690- }
691- // master pid code path
692- self.pid = pid;
693- std.log.info("pty spawned session={s} pid={d}", .{ self.session_name, pid });
694-
695- // make pty non-blocking
696- const flags = try lib_posix.fcntl(master_fd, lib_posix.F.GETFL, 0);
697- _ = try lib_posix.fcntl(master_fd, lib_posix.F.SETFL, flags | O_NONBLOCK);
698- return master_fd;
699- }
700-
701- /// ensureSession "upserts" a session by checking if the unix socket exists already.
702- /// If not it creates one and spawns the daemon.
703- fn ensureSession(self: *Daemon) !EnsureSessionResult {
704- std.log.info("ensure session session={s}", .{self.session_name});
705- var dir = try std.Io.Dir.openDirAbsolute(self.io, self.cfg.socket_dir, .{});
706- defer dir.close(self.io);
707-
708- const exists = try socket.sessionExists(self.io, dir, self.session_name);
709- var should_create = !exists;
710-
711- if (exists) {
712- if (ipc.connectSession(self.socket_path)) |fd| {
713- lib_posix.close(fd);
714- if (self.command != null) {
715- std.log.warn(
716- "session already exists, ignoring command session={s}",
717- .{self.session_name},
718- );
719- }
720- } else |err| switch (err) {
721- // Daemon is definitively gone: safe to replace.
722- error.ConnectionRefused => {
723- socket.cleanupStaleSocket(self.io, dir, self.session_name);
724- should_create = true;
725- },
726- // Connect failed for an unusual reason. The check is only to
727- // decide create-vs-attach; the socket file exists, so proceed
728- // to attach rather than fail or orphan.
729- else => {
730- std.log.warn(
731- "connect failed ({s}), proceeding to attach session={s}",
732- .{ @errorName(err), self.session_name },
733- );
734- },
735- }
736- }
737-
738- if (should_create) {
739- std.log.info("creating session={s}", .{self.session_name});
740- const server_sock_fd = try socket.createSocket(self.socket_path);
741-
742- // creates the daemon
743- const pid = try lib_posix.fork();
744- if (pid == 0) { // child (daemon)
745- // becomes the session leader and detaches process from its controlling terminal
746- _ = try lib_posix.setsid();
747-
748- log_system.deinit();
749-
750- // Redirect stdin/stdout/stderr to /dev/null. The daemon
751- // communicates via its unix socket, not stdio. Without
752- // this, any pipe on FDs 0-2 (e.g. from bats' `run`
753- // keyword) stays open for the daemon's lifetime, causing
754- // the caller to hang waiting for EOF.
755- {
756- const devnull = lib_posix.open(
757- "/dev/null",
758- .{ .ACCMODE = .RDWR },
759- 0,
760- ) catch |err| {
761- std.log.warn("failed to open /dev/null: {s}", .{@errorName(err)});
762- return err;
763- };
764- inline for (.{ lib_posix.STDIN_FILENO, lib_posix.STDOUT_FILENO, lib_posix.STDERR_FILENO }) |fd| {
765- _ = lib_posix.dup2(devnull, fd) catch |err| {
766- std.log.warn("dup2 /dev/null -> {d}: {s}", .{ fd, @errorName(err) });
767- return err;
768- };
769- }
770- if (devnull > 2) lib_posix.close(devnull);
771- }
772-
773- // Close file descriptors inherited from the parent that the
774- // daemon doesn't need. This prevents test harnesses (like
775- // bats) from hanging -- they wait for their internal FDs (3+)
776- // to close before exiting.
777- //
778- // Must run BEFORE log_system.init() otherwise the new log
779- // FD gets closed, and spawnPty() reuses that FD number for
780- // the PTY master, causing log writes to leak into the terminal.
781- //
782- // Skip server_sock_fd (needed for IPC) and dir.fd (needed to
783- // delete the socket file on shutdown).
784- {
785- const dir_fd = @as(i32, @intCast(dir.handle));
786- var fd: i32 = 3;
787- while (fd < 64) : (fd += 1) {
788- if (fd == server_sock_fd or fd == dir_fd) continue;
789- _ = std.c.close(fd);
790- }
791- }
792-
793- const session_log_name = try std.fmt.allocPrint(
794- self.alloc,
795- "{s}.log",
796- .{self.session_name},
797- );
798- defer self.alloc.free(session_log_name);
799- const session_log_path = try std.fs.path.join(
800- self.alloc,
801- &.{ self.cfg.log_dir, session_log_name },
802- );
803- defer self.alloc.free(session_log_path);
804- const log_mode = std.Io.File.Permissions.fromMode(self.cfg.log_mode);
805- try log_system.init(self.alloc, self.io, session_log_path, log_mode);
806-
807- // If spawnPty fails, clean up here. Once it succeeds,
808- // the inner block's defer takes ownership of cleanup to
809- // avoid double-closing server_sock_fd on daemonLoop error.
810- const pty_fd = self.spawnPty() catch |err| {
811- lib_posix.close(server_sock_fd);
812- dir.deleteFile(self.io, self.session_name) catch {};
813- return err;
814- };
815-
816- defer {
817- // Close and unlink the listen socket BEFORE handleKill()'s
818- // 500ms SIGHUP->SIGKILL grace sleep. Otherwise a `zmx run`
819- // for the same name issued in that window will hang waiting
820- // for a connect.
821- lib_posix.close(server_sock_fd);
822- std.log.info("deleting socket file session={s}", .{self.session_name});
823- dir.deleteFile(self.io, self.session_name) catch |err| {
824- std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
825- };
826- self.handleKill();
827- self.deinit();
828- lib_posix.close(pty_fd);
829- _ = lib_posix.waitpid(self.pid, 0);
830- }
831-
832- try daemonLoop(self, server_sock_fd, pty_fd);
833- std.log.info("daemon loop shutdown", .{});
834- return .{ .created = true, .is_daemon = true };
835- }
836- lib_posix.close(server_sock_fd);
837- std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(10), .real) catch unreachable;
838- return .{ .created = true, .is_daemon = false };
839- }
840-
841- return .{ .created = false, .is_daemon = false };
842- }
843-
844- const PTY_WRITE_BUF_MAX = 256 * 1024;
845-
846- /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT.
847- /// Drops the payload if the buffer is over cap -- same failure mode as
848- /// the old direct-write ptyWrite (drop on EAGAIN), just at a 64x higher
849- /// threshold. Capping avoids OOM when the shell stops reading; dropping
850- /// new (not old) bytes avoids tearing a partially-accepted sequence.
851- fn queuePtyInput(self: *Daemon, data: []const u8) void {
852- if (data.len == 0) return;
853- if (self.pty_write_buf.items.len + data.len > PTY_WRITE_BUF_MAX) {
854- std.log.warn(
855- "pty input dropped {d} bytes (buffer full, shell not reading)",
856- .{data.len},
857- );
858- return;
859- }
860- std.log.debug("buffering pty input data={x}", .{data});
861- self.pty_write_buf.appendSlice(self.alloc, data) catch |err| {
862- std.log.warn(
863- "pty input dropped {d} bytes: {s}",
864- .{ data.len, @errorName(err) },
865- );
866- };
867- }
868-
869- pub fn handleInput(self: *Daemon, client: *Client, payload: []const u8) !void {
870- std.log.debug("buffering pty input data={x}", .{payload});
871- // client is leader, send entire payload (ansi escape codes + text)
872- if (self.leader_client_fd == client.socket_fd) {
873- self.queuePtyInput(payload);
874- return;
875- }
876-
877- // check if leader needs to be updated by detecting any user input
878- if (util.isUserInput(payload)) {
879- try self.setLeader(client);
880- self.queuePtyInput(payload);
881- }
882- }
883-
884- /// Queue input from `zmx send` without changing interactive client leadership.
885- pub fn handleSend(self: *Daemon, payload: []const u8) void {
886- self.queuePtyInput(payload);
887- }
888-
889- pub fn handleSwitch(self: *Daemon, session_name: []const u8) !void {
890- for (self.clients.items) |client| {
891- if (self.leader_client_fd == client.socket_fd) {
892- ipc.appendMessage(
893- self.alloc,
894- &client.write_buf,
895- .Switch,
896- session_name,
897- ) catch |err| {
898- std.log.warn(
899- "failed to buffer terminal state for client err={s}",
900- .{@errorName(err)},
901- );
902- };
903- client.has_pending_output = true;
904- return;
905- }
906- }
907- return error.NoLeaderFound;
908- }
909-
910- pub fn handleInit(
911- self: *Daemon,
912- client: *Client,
913- pty_fd: i32,
914- term: *ghostty_vt.Terminal,
915- payload: []const u8,
916- ) !void {
917- if (payload.len != @sizeOf(ipc.Resize)) return;
918-
919- // Serialize terminal state BEFORE resize to capture correct cursor position.
920- // Resizing triggers reflow which can move the cursor, and the shell's
921- // SIGWINCH-triggered redraw will run after our snapshot is sent.
922- // Only serialize on re-attach (has_had_client), not first attach, to avoid
923- // interfering with shell initialization (DA1 queries, etc.)
924- if (self.has_pty_output and self.has_had_client) {
925- const cursor = &term.screens.active.cursor;
926- std.log.debug(
927- "cursor before serialize: x={d} y={d} pending_wrap={}",
928- .{ cursor.x, cursor.y, cursor.pending_wrap },
929- );
930- if (util.serializeTerminalState(self.alloc, term)) |term_output| {
931- std.log.debug("serialize terminal state", .{});
932- // Rewrite OSC 133;A to include redraw=0 so the outer terminal
933- // does not clear prompt lines on resize (issue #111).
934- const restore_data = util.rewritePromptRedraw(self.alloc, term_output) orelse term_output;
935- defer self.alloc.free(term_output);
936- defer if (restore_data.ptr != term_output.ptr) self.alloc.free(restore_data);
937- ipc.appendMessage(self.alloc, &client.write_buf, .Output, restore_data) catch |err| {
938- std.log.warn(
939- "failed to buffer terminal state for client err={s}",
940- .{@errorName(err)},
941- );
942- };
943- client.has_pending_output = true;
944- }
945- }
946-
947- // no leader is set so set one
948- if (self.leader_client_fd == null) {
949- try self.setLeader(client);
950- }
951-
952- // only resize if leader
953- if (self.leader_client_fd == client.socket_fd) {
954- const resize = std.mem.bytesToValue(ipc.Resize, payload);
955- var ws: cross.c.struct_winsize = .{
956- .ws_row = resize.rows,
957- .ws_col = resize.cols,
958- .ws_xpixel = resize.xpixel,
959- .ws_ypixel = resize.ypixel,
960- };
961- _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
962- // Disable prompt_redraw before resize. The daemon's internal terminal
963- // would otherwise clear prompt lines expecting the shell to redraw them,
964- // but the shell's redraw goes to the PTY (forwarded to clients), not to
965- // this daemon terminal. The clearing corrupts the daemon's snapshot state.
966- const saved_prompt_redraw = term.flags.shell_redraws_prompt;
967- term.flags.shell_redraws_prompt = .false;
968- defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
969- const opts = ghostty_vt.Terminal.Resize{
970- .cols = resize.cols,
971- .rows = resize.rows,
972- };
973- try term.resize(self.alloc, opts);
974-
975- // Mark that we've had a client init, so subsequent clients get terminal state
976- self.has_had_client = true;
977- self.has_terminal_client = true;
978-
979- std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
980- }
981- }
982-
983- pub fn handleResize(
984- self: *Daemon,
985- client: *Client,
986- pty_fd: i32,
987- term: *ghostty_vt.Terminal,
988- payload: []const u8,
989- ) !void {
990- if (payload.len != @sizeOf(ipc.Resize)) return;
991- if (self.leader_client_fd == null) {
992- try self.setLeader(client);
993- }
994- // only leader can resize
995- if (self.leader_client_fd != client.socket_fd) return;
996-
997- const resize = std.mem.bytesToValue(ipc.Resize, payload);
998- var ws: cross.c.struct_winsize = .{
999- .ws_row = resize.rows,
1000- .ws_col = resize.cols,
1001- .ws_xpixel = resize.xpixel,
1002- .ws_ypixel = resize.ypixel,
1003- };
1004- _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
1005- // Disable prompt_redraw before resize (same rationale as handleInit).
1006- const saved_prompt_redraw = term.flags.shell_redraws_prompt;
1007- term.flags.shell_redraws_prompt = .false;
1008- defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
1009- const opts = ghostty_vt.Terminal.Resize{
1010- .cols = resize.cols,
1011- .rows = resize.rows,
1012- };
1013- try term.resize(self.alloc, opts);
1014- std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
1015- }
1016-
1017- pub fn handleDetach(self: *Daemon, client: *Client, i: usize) void {
1018- std.log.info("client detach session={s} fd={d}", .{ self.session_name, client.socket_fd });
1019- _ = self.closeClient(client, i, false);
1020- }
1021-
1022- pub fn handleDetachAll(self: *Daemon) void {
1023- std.log.info("detach all clients={d}", .{self.clients.items.len});
1024- for (self.clients.items) |client_to_close| {
1025- client_to_close.deinit();
1026- self.alloc.destroy(client_to_close);
1027- }
1028- self.clients.clearRetainingCapacity();
1029- }
1030-
1031- pub fn handleKill(self: *Daemon) void {
1032- std.log.info("kill received session={s}", .{self.session_name});
1033- self.shutdown();
1034- // gracefully shutdown shell processes, shells tend to ignore SIGTERM so we send SIGHUP
1035- // instead
1036- // https://www.gnu.org/software/bash/manual/html_node/Signals.html
1037- // negative pid means kill process and children
1038- std.log.info("sending SIGHUP session={s} pid={d}", .{ self.session_name, self.pid });
1039- lib_posix.kill(-self.pid, lib_posix.SIG.HUP) catch |err| {
1040- std.log.warn("failed to send SIGHUP to pty child err={s}", .{@errorName(err)});
1041- };
1042- std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(500), .real) catch unreachable;
1043- lib_posix.kill(-self.pid, lib_posix.SIG.KILL) catch |err| {
1044- std.log.warn("failed to send SIGKILL to pty child err={s}", .{@errorName(err)});
1045- };
1046- }
1047-
1048- pub fn handleInfo(self: *Daemon, client: *Client) !void {
1049- // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd
1050- // tail bytes (daemon stack contents) to clients.
1051- var info = std.mem.zeroes(ipc.Info);
1052- info.clients_len = self.clients.items.len - 1;
1053- info.pid = self.pid;
1054- info.created_at = self.created_at;
1055- info.task_ended_at = self.task_ended_at orelse 0;
1056- info.task_exit_code = self.task_exit_code orelse 0;
1057-
1058- // Build command string from args, re-quoting args that contain
1059- // shell-special characters so the displayed command is copy-pasteable.
1060- const cur_cmd = self.command;
1061- if (cur_cmd) |args| {
1062- for (args, 0..) |arg, i| {
1063- const quoted = if (util.shellNeedsQuoting(arg))
1064- util.shellQuote(self.alloc, arg) catch null
1065- else
1066- null;
1067- defer if (quoted) |q| self.alloc.free(q);
1068- const src = quoted orelse arg;
1069-
1070- const need = src.len + @as(usize, if (i > 0) 1 else 0);
1071- if (info.cmd_len + need > ipc.MAX_CMD_LEN) {
1072- const ellipsis = "...";
1073- if (info.cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) {
1074- @memcpy(info.cmd[info.cmd_len..][0..ellipsis.len], ellipsis);
1075- info.cmd_len += ellipsis.len;
1076- }
1077- break;
1078- }
1079-
1080- if (i > 0) {
1081- info.cmd[info.cmd_len] = ' ';
1082- info.cmd_len += 1;
1083- }
1084- @memcpy(info.cmd[info.cmd_len..][0..src.len], src);
1085- info.cmd_len += @intCast(src.len);
1086- }
1087- }
1088-
1089- info.cwd_len = @intCast(@min(self.cwd.len, ipc.MAX_CWD_LEN));
1090- @memcpy(info.cwd[0..info.cwd_len], self.cwd[0..info.cwd_len]);
1091-
1092- try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
1093- client.has_pending_output = true;
1094- }
1095-
1096- pub fn handleHistory(
1097- self: *Daemon,
1098- client: *Client,
1099- term: *ghostty_vt.Terminal,
1100- payload: []const u8,
1101- ) !void {
1102- const format: util.HistoryFormat = if (payload.len > 0)
1103- @enumFromInt(payload[0])
1104- else
1105- .plain;
1106- if (util.serializeTerminal(self.alloc, term, format)) |output| {
1107- defer self.alloc.free(output);
1108- try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
1109- client.has_pending_output = true;
1110- } else {
1111- try ipc.appendMessage(self.alloc, &client.write_buf, .History, "");
1112- client.has_pending_output = true;
1113- }
1114- }
1115-
1116- pub fn handleRun(self: *Daemon, client: *Client, payload: []const u8) !void {
1117- // Reset task tracking so the new command's exit marker is detected.
1118- // Without this, a second `zmx run` on the same session is ignored
1119- // because task_exit_code is still set from the first run.
1120- self.task_exit_code = null;
1121- self.task_ended_at = null;
1122- self.is_task_mode = true;
1123-
1124- if (payload.len == 0) return;
1125-
1126- const cmd = payload;
1127-
1128- // Chain the exit marker with `;` on the same line. `$?` captures the
1129- // exit code of the command (not the `;`). The sole exception is when
1130- // the command contains a heredoc (`<<`), the delimiter must be alone
1131- // on its line, so the marker goes on the next line instead.
1132- const single_line_marker = "; echo ZMX_TASK_COMPLETED:$?\r";
1133- const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
1134- const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
1135-
1136- if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
1137- self.queuePtyInput(cmd[0 .. cmd.len - 1]);
1138- } else {
1139- self.queuePtyInput(cmd);
1140- }
1141- self.queuePtyInput(if (uses_heredoc) heredoc_marker else single_line_marker);
1142-
1143- try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
1144- client.has_pending_output = true;
1145- self.has_had_client = true;
1146- std.log.debug("run command len={d}", .{payload.len});
1147- }
1148-
1149- pub fn handleOutput(self: *Daemon, payload: []const u8, vt_stream: anytype) !void {
1150- vt_stream.nextSlice(payload);
1151- self.has_pty_output = true;
1152- for (self.clients.items) |client| {
1153- try ipc.appendMessage(self.alloc, &client.write_buf, .Output, payload);
1154- client.has_pending_output = true;
1155- }
1156- if (self.clients.items.len > 0) {
1157- lib_posix.kill(self.pid, lib_posix.SIG.WINCH) catch |err| {
1158- std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
1159- };
1160- }
1161- }
1162-
1163- pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
1164- // Wire format: [u32 path len][path bytes][file content]
1165- if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
1166- const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
1167- if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
1168- const file_path = payload[@sizeOf(u32)..][0..path_len];
1169- const file_content = payload[@sizeOf(u32) + path_len ..];
1170-
1171- // Inject file creation through the PTY so it works over SSH.
1172- // Base64-encode content and pipe through printf | base64 -d > file.
1173- // Chunk large files to stay under command-line length limits.
1174- // 48000 is divisible by 3 (clean base64 boundaries) and encodes
1175- // to ~64KB, well under typical ARG_MAX.
1176- const chunk_size = 48000;
1177- var offset: usize = 0;
1178- var is_first = true;
1179-
1180- while (offset < file_content.len or is_first) {
1181- const end = @min(offset + chunk_size, file_content.len);
1182- const chunk = file_content[offset..end];
1183-
1184- const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
1185- const encoded = try self.alloc.alloc(u8, encoded_len);
1186- defer self.alloc.free(encoded);
1187- _ = std.base64.standard.Encoder.encode(encoded, chunk);
1188-
1189- self.queuePtyInput("printf '%s' '");
1190- self.queuePtyInput(encoded);
1191- if (is_first) {
1192- self.queuePtyInput("' | base64 -d > '");
1193- } else {
1194- self.queuePtyInput("' | base64 -d >> '");
1195- }
1196- self.queuePtyInput(file_path);
1197- self.queuePtyInput("'");
1198- self.queuePtyInput("\r");
1199-
1200- offset = end;
1201- is_first = false;
1202- }
1203-
1204- try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
1205- client.has_pending_output = true;
1206- self.has_had_client = true;
1207- std.log.debug(
1208- "write command len={d} file_path={s}",
1209- .{ file_content.len, file_path },
1210- );
1211- }
1212-};
1213-
1214-test "send queues PTY input without changing leader" {
1215- const alloc = std.testing.allocator;
1216- var daemon = Daemon{
1217- .cfg = undefined,
1218- .alloc = alloc,
1219- .clients = .empty,
1220- .leader_client_fd = 42,
1221- .session_name = "test",
1222- .socket_path = "",
1223- .io = std.testing.io,
1224- .running = true,
1225- .pid = 0,
1226- .created_at = 0,
1227- };
1228- defer daemon.pty_write_buf.deinit(alloc);
1229-
1230- daemon.handleSend("hello");
1231-
1232- try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
1233- try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
1234-}
1235-
1236-fn printVersion(io: std.Io, cfg: *Cfg) !void {
1237- var buf: [256]u8 = undefined;
1238- var w = std.Io.File.stdout().writer(io, &buf);
1239- try w.interface.print(
1240- "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
1241- .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
1242- );
1243- try w.interface.flush();
1244-}
1245-
1246-fn printCompletions(io: std.Io, shell: completions.Shell) !void {
1247- const script = shell.getCompletionScript();
1248- var buf: [8192]u8 = undefined;
1249- var w = std.Io.File.stdout().writer(io, &buf);
1250- try w.interface.print("{s}\n", .{script});
1251- try w.interface.flush();
1252-}
1253-
1254 fn help(io: std.Io) !void {
1255 const help_text =
1256 \\zmx - session persistence for terminal processes
1257@@ -1578,6 +547,28 @@ fn help(io: std.Io) !void {
1258 try w.interface.flush();
1259 }
1260
1261+fn printVersion(io: std.Io, cfg: *Cfg) !void {
1262+ var buf: [256]u8 = undefined;
1263+ var w = std.Io.File.stdout().writer(io, &buf);
1264+ try w.interface.print(
1265+ "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
1266+ .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
1267+ );
1268+ try w.interface.flush();
1269+}
1270+
1271+fn printCompletions(io: std.Io, shell: completions.Shell) !void {
1272+ const script = shell.getCompletionScript();
1273+ var buf: [8192]u8 = undefined;
1274+ var w = std.Io.File.stdout().writer(io, &buf);
1275+ try w.interface.print("{s}\n", .{script});
1276+ try w.interface.flush();
1277+}
1278+
1279+fn detectHelp(arg: []const u8) bool {
1280+ return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
1281+}
1282+
1283 fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
1284 var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
1285 defer poll_fds.deinit(alloc);
1286@@ -1731,7 +722,7 @@ fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detache
1287 }
1288 }
1289
1290-fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(SessionMatch)) !void {
1291+fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(socket.SessionMatch)) !void {
1292 var stdout_buffer: [1024]u8 = undefined;
1293 var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
1294 const stdout = &stdout_writer.interface;
1295@@ -1747,7 +738,7 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1296 var zero_match_iters: u32 = 0;
1297
1298 var agg_exit_code: u8 = 0;
1299- var last_print: i96 = 0;
1300+ var last_print: std.Io.Timestamp = .zero;
1301 var prev_done: i32 = 0;
1302 while (true) {
1303 agg_exit_code = 0;
1304@@ -1775,7 +766,11 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1305 // waiting". Count it as done+failed so wait terminates.
1306 try stderr.print(
1307 "[{d}] task unreachable: {s} ({s})\n",
1308- .{ std.Io.Timestamp.now(io, .real).nanoseconds, session.name, session.error_name orelse "unknown" },
1309+ .{
1310+ std.Io.Timestamp.now(io, .real).toSeconds(),
1311+ session.name,
1312+ session.error_name orelse "unknown",
1313+ },
1314 );
1315 try stderr.flush();
1316 agg_exit_code = 1;
1317@@ -1783,11 +778,11 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1318 continue;
1319 }
1320 if (session.task_ended_at == 0) {
1321- const now = std.Io.Timestamp.now(io, .real).nanoseconds;
1322- if (now - last_print >= 5) {
1323+ const now = std.Io.Timestamp.now(io, .real);
1324+ if (now.toSeconds() - last_print.toSeconds() >= 5) {
1325 try stdout.print(
1326 "[{d}] waiting task={s}\n",
1327- .{ now, session.name },
1328+ .{ now.toSeconds(), session.name },
1329 );
1330 try stdout.flush();
1331 last_print = now;
1332@@ -2254,32 +1249,32 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons
1333 }
1334 }
1335
1336-fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
1337+fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh: []const u8) !void {
1338 // we want daemon.session_name because that's the session name the user provided during zmx attach
1339 // instead of the name of the session they are currently inside of.
1340 const next_session = daemon.session_name;
1341 std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
1342
1343- const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1344- error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, current_sesh, daemon.cfg.socket_dir),
1345+ const socket_path = socket.getSocketPath(gpa, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1346+ error.NameTooLong => return socket.printSessionNameTooLong(io, current_sesh, daemon.cfg.socket_dir),
1347 error.OutOfMemory => return err,
1348 };
1349- defer daemon.alloc.free(socket_path);
1350+ defer gpa.free(socket_path);
1351
1352- var dir = try std.Io.Dir.openDirAbsolute(daemon.io, daemon.cfg.socket_dir, .{});
1353- defer dir.close(daemon.io);
1354+ var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1355+ defer dir.close(io);
1356
1357- const exists = try socket.sessionExists(daemon.io, dir, current_sesh);
1358+ const exists = try socket.sessionExists(io, dir, current_sesh);
1359 if (!exists) {
1360 var buf: [4096]u8 = undefined;
1361- var w = std.Io.File.stderr().writer(daemon.io, &buf);
1362+ var w = std.Io.File.stderr().writer(io, &buf);
1363 w.interface.print("error: session \"{s}\" does not exist\n", .{current_sesh}) catch {};
1364 w.interface.flush() catch {};
1365 return error.SessionNotFound;
1366 }
1367 const fd = ipc.connectSession(socket_path) catch |err| {
1368 std.log.err("session unresponsive: {s}", .{@errorName(err)});
1369- if (err == error.ConnectionRefused) socket.cleanupStaleSocket(daemon.io, dir, current_sesh);
1370+ if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, current_sesh);
1371 return;
1372 };
1373 defer lib_posix.close(fd);
1374@@ -2290,14 +1285,14 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
1375 };
1376 }
1377
1378-fn attach(daemon: *Daemon) !void {
1379+fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
1380 const sesh = socket.getSeshNameFromEnv();
1381 if (sesh.len > 0) {
1382- return switchSesh(daemon, sesh);
1383+ return switchSesh(gpa, io, daemon, sesh);
1384 }
1385
1386- const result = try daemon.ensureSession();
1387- if (result.is_daemon) return;
1388+ const is_daemon_proc = try daemon.ensureSession(io);
1389+ if (is_daemon_proc) return;
1390
1391 const client_sock = try socket.sessionConnect(daemon.socket_path);
1392 std.log.info("attached session={s}", .{daemon.session_name});
1393@@ -2347,60 +1342,45 @@ fn attach(daemon: *Daemon) !void {
1394 const clear_seq = "\x1b[2J\x1b[H";
1395 _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
1396
1397- const looper = try clientLoop(client_sock);
1398+ const looper = try loop.clientLoop(client_sock);
1399 switch (looper.kind) {
1400 .detach => return,
1401 .switch_session => {
1402 if (looper.session_name) |session_name| {
1403 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
1404- const cwd_len = std.process.currentPath(daemon.io, &cwd_buf) catch 0;
1405+ const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
1406 const cwd = cwd_buf[0..cwd_len];
1407 const target_path = socket.getSocketPath(
1408- daemon.alloc,
1409+ gpa,
1410 daemon.cfg.socket_dir,
1411 session_name,
1412 ) catch |err| switch (err) {
1413 error.NameTooLong => return socket.printSessionNameTooLong(
1414- daemon.io,
1415+ io,
1416 session_name,
1417 daemon.cfg.socket_dir,
1418 ),
1419 error.OutOfMemory => return err,
1420 };
1421
1422- const clients = try std.ArrayList(*Client).initCapacity(daemon.alloc, 10);
1423- var target_daemon = Daemon{
1424- .io = daemon.io,
1425- .running = true,
1426- .cfg = daemon.cfg,
1427- .alloc = daemon.alloc,
1428- .clients = clients,
1429- .session_name = session_name,
1430- .socket_path = target_path,
1431- .pid = undefined,
1432- .cwd = cwd,
1433- .created_at = @intCast(std.Io.Timestamp.now(daemon.io, .real).nanoseconds),
1434- .leader_client_fd = null,
1435- };
1436- return attach(&target_daemon);
1437+ var target_daemon = Daemon.init(io, daemon.cfg, session_name, target_path);
1438+ target_daemon.cwd = cwd;
1439+ return attach(gpa, io, &target_daemon);
1440 }
1441 },
1442 }
1443 }
1444
1445-fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1446+fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []const u8) !void {
1447+ const is_daemon_proc = try daemon.ensureSession(io);
1448+ if (is_daemon_proc) return;
1449+
1450 var buf: [4096]u8 = undefined;
1451- var w = std.Io.File.stdout().writer(daemon.io, &buf);
1452- const sesh_result = try daemon.ensureSession();
1453- if (sesh_result.is_daemon) return;
1454+ var w = std.Io.File.stdout().writer(io, &buf);
1455
1456- if (sesh_result.created) {
1457- try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
1458- try w.interface.flush();
1459- }
1460 const stdin_fd = lib_posix.STDIN_FILENO;
1461- var stdin_buf = try std.ArrayList(u8).initCapacity(daemon.alloc, 4096);
1462- defer stdin_buf.deinit(daemon.alloc);
1463+ var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1464+ defer stdin_buf.deinit(gpa);
1465
1466 while (true) {
1467 var tmp: [4096]u8 = undefined;
1468@@ -2409,28 +1389,28 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1469 return err;
1470 };
1471 if (n == 0) break;
1472- try stdin_buf.appendSlice(daemon.alloc, tmp[0..n]);
1473+ try stdin_buf.appendSlice(gpa, tmp[0..n]);
1474 }
1475
1476 const socket_path = socket.getSocketPath(
1477- daemon.alloc,
1478+ gpa,
1479 daemon.cfg.socket_dir,
1480 daemon.session_name,
1481 ) catch |err| switch (err) {
1482 error.NameTooLong => return socket.printSessionNameTooLong(
1483- daemon.io,
1484+ io,
1485 daemon.session_name,
1486 daemon.cfg.socket_dir,
1487 ),
1488 error.OutOfMemory => return err,
1489 };
1490- var dir = try std.Io.Dir.openDirAbsolute(daemon.io, daemon.cfg.socket_dir, .{});
1491- defer dir.close(daemon.io);
1492+ var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1493+ defer dir.close(io);
1494
1495- const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
1496+ const result = ipc.probeSession(gpa, socket_path) catch |err| {
1497 std.log.err("session unresponsive: {s}", .{@errorName(err)});
1498 if (err == error.ConnectionRefused) {
1499- socket.cleanupStaleSocket(daemon.io, dir, daemon.session_name);
1500+ socket.cleanupStaleSocket(io, dir, daemon.session_name);
1501 w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1502 } else {
1503 w.interface.print(
1504@@ -2446,21 +1426,21 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1505
1506 // Build wire payload: [u32 path len][path bytes][file content]
1507 var wire_buf = try std.ArrayList(u8).initCapacity(
1508- daemon.alloc,
1509+ gpa,
1510 @sizeOf(u32) + file_path.len + stdin_buf.items.len,
1511 );
1512- defer wire_buf.deinit(daemon.alloc);
1513+ defer wire_buf.deinit(gpa);
1514 const path_len: u32 = @intCast(file_path.len);
1515- try wire_buf.appendSlice(daemon.alloc, std.mem.asBytes(&path_len));
1516- try wire_buf.appendSlice(daemon.alloc, file_path);
1517- try wire_buf.appendSlice(daemon.alloc, stdin_buf.items);
1518+ try wire_buf.appendSlice(gpa, std.mem.asBytes(&path_len));
1519+ try wire_buf.appendSlice(gpa, file_path);
1520+ try wire_buf.appendSlice(gpa, stdin_buf.items);
1521
1522 ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
1523 error.BrokenPipe, error.ConnectionResetByPeer => return,
1524 else => return err,
1525 };
1526
1527- var sb = try ipc.SocketBuffer.init(daemon.alloc);
1528+ var sb = try ipc.SocketBuffer.init(gpa);
1529 defer sb.deinit();
1530
1531 const n = sb.read(result.fd) catch return error.ReadFailed;
1532@@ -2539,35 +1519,26 @@ fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u
1533 };
1534 }
1535
1536-fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1537- const alloc = daemon.alloc;
1538- var buf: [4096]u8 = undefined;
1539- var w = std.Io.File.stdout().writer(daemon.io, &buf);
1540-
1541+fn run(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1542 var cmd_to_send: ?[]const u8 = null;
1543 var allocated_cmd: ?[]u8 = null;
1544- defer if (allocated_cmd) |cmd| alloc.free(cmd);
1545+ defer if (allocated_cmd) |cmd| gpa.free(cmd);
1546
1547- const result = try daemon.ensureSession();
1548- if (result.is_daemon) return;
1549-
1550- if (result.created) {
1551- try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
1552- try w.interface.flush();
1553- }
1554+ const is_daemon_proc = try daemon.ensureSession(io);
1555+ if (is_daemon_proc) return;
1556
1557 if (command_args.len > 0) {
1558 var cmd_list = std.ArrayList(u8).empty;
1559- defer cmd_list.deinit(alloc);
1560+ defer cmd_list.deinit(gpa);
1561
1562 for (command_args, 0..) |arg, i| {
1563- if (i > 0) try cmd_list.append(alloc, ' ');
1564+ if (i > 0) try cmd_list.append(gpa, ' ');
1565 if (util.shellNeedsQuoting(arg)) {
1566- const quoted = try util.shellQuote(alloc, arg);
1567- defer alloc.free(quoted);
1568- try cmd_list.appendSlice(alloc, quoted);
1569+ const quoted = try util.shellQuote(gpa, arg);
1570+ defer gpa.free(quoted);
1571+ try cmd_list.appendSlice(gpa, quoted);
1572 } else {
1573- try cmd_list.appendSlice(alloc, arg);
1574+ try cmd_list.appendSlice(gpa, arg);
1575 }
1576 }
1577
1578@@ -2575,24 +1546,24 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1579 // raw mode; readline's accept-line binds to CR. The first-ever run
1580 // works with \n only because it arrives during shell startup while
1581 // the line discipline is still canonical.
1582- try cmd_list.append(alloc, '\r');
1583+ try cmd_list.append(gpa, '\r');
1584
1585- cmd_to_send = try cmd_list.toOwnedSlice(alloc);
1586+ cmd_to_send = try cmd_list.toOwnedSlice(gpa);
1587 allocated_cmd = @constCast(cmd_to_send.?);
1588 } else {
1589 // Read from stdin when no text arguments provided.
1590 const stdin_file = std.Io.File.stdin();
1591- defer stdin_file.close(daemon.io);
1592- var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1593- defer stdin_buf.deinit(alloc);
1594+ defer stdin_file.close(io);
1595+ var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1596+ defer stdin_buf.deinit(gpa);
1597 var stdbuf: [4096]u8 = undefined;
1598- var reader = stdin_file.reader(daemon.io, &stdbuf);
1599- if (!try stdin_file.isTty(daemon.io)) {
1600+ var reader = stdin_file.reader(io, &stdbuf);
1601+ if (!try stdin_file.isTty(io)) {
1602 while (true) {
1603 var dest: [1024]u8 = undefined;
1604 const n = try reader.interface.readSliceShort(&dest);
1605 if (n == 0) break; // EOF
1606- try stdin_buf.appendSlice(alloc, dest[0..n]);
1607+ try stdin_buf.appendSlice(gpa, dest[0..n]);
1608 }
1609
1610 if (stdin_buf.items.len > 0) {
1611@@ -2601,42 +1572,13 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1612 if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1613 stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1614 } else {
1615- try stdin_buf.append(alloc, '\r');
1616+ try stdin_buf.append(gpa, '\r');
1617 }
1618
1619- cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
1620+ cmd_to_send = try gpa.dupe(u8, stdin_buf.items);
1621 allocated_cmd = @constCast(cmd_to_send.?);
1622 }
1623 }
1624-
1625- // const stdin_fd = posix.STDIN_FILENO;
1626- // if (!lib_posix.isatty(stdin_fd)) {
1627- // var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1628- // defer stdin_buf.deinit(alloc);
1629-
1630- // while (true) {
1631- // var tmp: [4096]u8 = undefined;
1632- // const n = posix.read(stdin_fd, &tmp) catch |err| {
1633- // if (err == error.WouldBlock) break;
1634- // return err;
1635- // };
1636- // if (n == 0) break;
1637- // try stdin_buf.appendSlice(alloc, tmp[0..n]);
1638- // }
1639-
1640- // if (stdin_buf.items.len > 0) {
1641- // // Normalize any trailing newline to CR so readline (raw mode)
1642- // // accepts each line.
1643- // if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1644- // stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1645- // } else {
1646- // try stdin_buf.append(alloc, '\r');
1647- // }
1648-
1649- // cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
1650- // allocated_cmd = @constCast(cmd_to_send.?);
1651- // }
1652- // }
1653 }
1654
1655 if (cmd_to_send == null) {
1656@@ -2649,529 +1591,15 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1657 };
1658 defer lib_posix.close(client_sock);
1659
1660- var fds = try std.ArrayList(i32).initCapacity(alloc, 1);
1661- defer fds.deinit(alloc);
1662- try fds.append(alloc, client_sock);
1663+ var fds = try std.ArrayList(i32).initCapacity(gpa, 1);
1664+ defer fds.deinit(gpa);
1665+ try fds.append(gpa, client_sock);
1666
1667 ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
1668 error.ConnectionResetByPeer, error.BrokenPipe => return,
1669 else => return err,
1670 };
1671
1672- const exit_code = try tail(daemon.alloc, fds, detached, true);
1673+ const exit_code = try tail(gpa, fds, detached, true);
1674 lib_posix.exit(exit_code);
1675 }
1676-
1677-const ClientResult = struct {
1678- kind: enum {
1679- detach,
1680- switch_session,
1681- },
1682- session_name: ?[]const u8,
1683-};
1684-
1685-/// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
1686-/// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
1687-fn clientLoop(client_sock_fd: i32) !ClientResult {
1688- std.log.info("client loop fd={d}", .{client_sock_fd});
1689- // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
1690- const alloc = std.heap.c_allocator;
1691- defer lib_posix.close(client_sock_fd);
1692-
1693- try openSignalPipe();
1694- installWakeHandler(@intFromEnum(lib_posix.SIG.WINCH));
1695-
1696- // Make socket non-blocking to avoid blocking on writes
1697- var sock_flags = try lib_posix.fcntl(client_sock_fd, lib_posix.F.GETFL, 0);
1698- sock_flags |= O_NONBLOCK;
1699- _ = try lib_posix.fcntl(client_sock_fd, lib_posix.F.SETFL, sock_flags);
1700-
1701- // Buffer for outgoing socket writes
1702- var sock_write_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1703- defer sock_write_buf.deinit(alloc);
1704-
1705- // Send init message with terminal size (buffered)
1706- const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1707- try ipc.appendMessage(alloc, &sock_write_buf, .Init, std.mem.asBytes(&size));
1708-
1709- var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
1710- defer poll_fds.deinit(alloc);
1711-
1712- var read_buf = try ipc.SocketBuffer.init(alloc);
1713- defer read_buf.deinit();
1714-
1715- var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1716- defer stdout_buf.deinit(alloc);
1717-
1718- const stdin_fd = lib_posix.STDIN_FILENO;
1719-
1720- // Make stdin non-blocking. O_NONBLOCK is set on the open file description,
1721- // which is shared with the parent shell; restore on exit to avoid
1722- // corrupting the parent's stdin.
1723- const stdin_orig_flags = try lib_posix.fcntl(stdin_fd, lib_posix.F.GETFL, 0);
1724- _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | O_NONBLOCK);
1725- defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
1726-
1727- while (true) {
1728- poll_fds.clearRetainingCapacity();
1729-
1730- try poll_fds.append(alloc, .{
1731- .fd = stdin_fd,
1732- .events = lib_posix.POLL.IN,
1733- .revents = 0,
1734- });
1735-
1736- // Poll socket for read, and also for write if we have pending data
1737- var sock_events: i16 = lib_posix.POLL.IN;
1738- if (sock_write_buf.items.len > 0) {
1739- sock_events |= lib_posix.POLL.OUT;
1740- }
1741- try poll_fds.append(alloc, .{
1742- .fd = client_sock_fd,
1743- .events = sock_events,
1744- .revents = 0,
1745- });
1746-
1747- try poll_fds.append(alloc, .{ .fd = sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
1748-
1749- if (stdout_buf.items.len > 0) {
1750- try poll_fds.append(alloc, .{
1751- .fd = lib_posix.STDOUT_FILENO,
1752- .events = lib_posix.POLL.OUT,
1753- .revents = 0,
1754- });
1755- }
1756-
1757- _ = try lib_posix.poll(poll_fds.items, -1);
1758-
1759- if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
1760- drainSignalPipe();
1761- const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1762- try ipc.appendMessage(alloc, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
1763- }
1764-
1765- // Handle stdin -> socket (Input)
1766- const inp_flags = (lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL);
1767- if (poll_fds.items[0].revents & inp_flags != 0) {
1768- var buf: [4096]u8 = undefined;
1769- const n_opt: ?usize = lib_posix.read(stdin_fd, &buf) catch |err| blk: {
1770- if (err == error.WouldBlock) break :blk null;
1771- return err;
1772- };
1773-
1774- if (n_opt) |n| {
1775- if (n > 0) {
1776- // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
1777- if (util.isCtrlBackslash(buf[0..n])) {
1778- std.log.info("detach key detected", .{});
1779- try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
1780- } else {
1781- try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
1782- }
1783- } else {
1784- std.log.info("eof stdin", .{});
1785- // EOF on stdin
1786- return ClientResult{ .kind = .detach, .session_name = null };
1787- }
1788- }
1789- }
1790-
1791- // Handle socket read (incoming Output messages from daemon)
1792- if (poll_fds.items[1].revents & lib_posix.POLL.IN != 0) {
1793- const n = read_buf.read(client_sock_fd) catch |err| {
1794- if (err == error.WouldBlock) continue;
1795- if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
1796- return ClientResult{ .kind = .detach, .session_name = null };
1797- }
1798- std.log.err("daemon read err={s}", .{@errorName(err)});
1799- return err;
1800- };
1801- if (n == 0) {
1802- std.log.info("server closed connection", .{});
1803- // Server closed connection
1804- return ClientResult{ .kind = .detach, .session_name = null };
1805- }
1806-
1807- while (read_buf.next()) |msg| {
1808- switch (msg.header.tag) {
1809- .Output => {
1810- if (msg.payload.len > 0) {
1811- try stdout_buf.appendSlice(alloc, msg.payload);
1812- }
1813- },
1814- .Resize => {
1815- // daemon is asking for the client's window size usually in response
1816- // to this client being set as leader.
1817- const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1818- try ipc.appendMessage(
1819- alloc,
1820- &sock_write_buf,
1821- .Resize,
1822- std.mem.asBytes(&next_size),
1823- );
1824- },
1825- .Switch => {
1826- std.log.info("switch session", .{});
1827- return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) };
1828- },
1829- else => {},
1830- }
1831- }
1832- }
1833-
1834- // Handle socket write (flush buffered messages to daemon)
1835- if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
1836- if (sock_write_buf.items.len > 0) {
1837- const n = lib_posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
1838- if (err == error.WouldBlock) break :blk 0;
1839- if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
1840- std.log.info("connection reset or broken pipe", .{});
1841- return ClientResult{ .kind = .detach, .session_name = null };
1842- }
1843- return err;
1844- };
1845- if (n > 0) {
1846- try sock_write_buf.replaceRange(alloc, 0, n, &[_]u8{});
1847- }
1848- }
1849- }
1850-
1851- if (stdout_buf.items.len > 0) {
1852- const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
1853- if (err == error.WouldBlock) break :blk 0;
1854- return err;
1855- };
1856- if (n > 0) {
1857- try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
1858- }
1859- }
1860-
1861- if (poll_fds.items[1].revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
1862- std.log.info("poll hup|err|nval", .{});
1863- return ClientResult{ .kind = .detach, .session_name = null };
1864- }
1865- }
1866-}
1867-
1868-/// dameonLoop is what the daemon runs to send and receive ipc commands from its corresponding
1869-/// clients. It uses poll() as its non-blocking mechanism.
1870-fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
1871- std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
1872- daemon.pty_fd = pty_fd;
1873- try openSignalPipe();
1874- installWakeHandler(@intFromEnum(lib_posix.SIG.TERM));
1875- var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(daemon.alloc, 8);
1876- defer poll_fds.deinit(daemon.alloc);
1877-
1878- const init_size = ipc.getTerminalSize(pty_fd);
1879- var term = try ghostty_vt.Terminal.init(daemon.io, daemon.alloc, .{
1880- .cols = init_size.cols,
1881- .rows = init_size.rows,
1882- .max_scrollback = daemon.cfg.max_scrollback,
1883- });
1884- defer term.deinit(daemon.alloc);
1885- var vt_stream = term.vtStream();
1886- defer vt_stream.deinit();
1887-
1888- // Carries the tail of the previous PTY read so the task-exit marker
1889- // search below can see across a read() boundary. Sized to comfortably
1890- // hold "ZMX_TASK_COMPLETED:" (19 bytes) plus a u8 exit code and CRLF.
1891- var marker_carry: [32]u8 = undefined;
1892- var marker_carry_len: usize = 0;
1893-
1894- daemon_loop: while (daemon.running) {
1895- poll_fds.clearRetainingCapacity();
1896-
1897- try poll_fds.append(daemon.alloc, .{
1898- .fd = server_sock_fd,
1899- .events = lib_posix.POLL.IN,
1900- .revents = 0,
1901- });
1902-
1903- var pty_events: i16 = lib_posix.POLL.IN;
1904- if (daemon.pty_write_buf.items.len > 0) {
1905- pty_events |= lib_posix.POLL.OUT;
1906- }
1907- try poll_fds.append(daemon.alloc, .{
1908- .fd = pty_fd,
1909- .events = pty_events,
1910- .revents = 0,
1911- });
1912-
1913- try poll_fds.append(daemon.alloc, .{ .fd = sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
1914-
1915- for (daemon.clients.items) |client| {
1916- var events: i16 = lib_posix.POLL.IN;
1917- if (client.has_pending_output) {
1918- events |= lib_posix.POLL.OUT;
1919- }
1920- try poll_fds.append(daemon.alloc, .{
1921- .fd = client.socket_fd,
1922- .events = events,
1923- .revents = 0,
1924- });
1925- }
1926-
1927- _ = try lib_posix.poll(poll_fds.items, -1);
1928-
1929- if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
1930- drainSignalPipe();
1931- std.log.info(
1932- "SIGTERM received, shutting down gracefully session={s}",
1933- .{daemon.session_name},
1934- );
1935- break :daemon_loop;
1936- }
1937-
1938- if (poll_fds.items[0].revents & (lib_posix.POLL.ERR | lib_posix.POLL.HUP | lib_posix.POLL.NVAL) != 0) {
1939- std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
1940- break :daemon_loop;
1941- } else if (poll_fds.items[0].revents & lib_posix.POLL.IN != 0) {
1942- const client_fd = try lib_posix.accept(
1943- server_sock_fd,
1944- null,
1945- null,
1946- lib_posix.SOCK.NONBLOCK | lib_posix.SOCK.CLOEXEC,
1947- );
1948- const client = try daemon.alloc.create(Client);
1949- client.* = Client{
1950- .alloc = daemon.alloc,
1951- .socket_fd = client_fd,
1952- .read_buf = try ipc.SocketBuffer.init(daemon.alloc),
1953- .write_buf = undefined,
1954- };
1955- // 64KB initial capacity lets ~15 broadcast cycles (N_TTY_BUF_SIZE reads
1956- // * header) accumulate before the first ArrayList growth. The write
1957- // buffer is userspace-only: it drains via POLLOUT to the client socket,
1958- // which has no corresponding kernel-imposed per-write limit.
1959- client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 65536);
1960- try daemon.clients.append(daemon.alloc, client);
1961- std.log.info(
1962- "client connected fd={d} total={d}",
1963- .{ client_fd, daemon.clients.items.len },
1964- );
1965- }
1966-
1967- const inp_flags = lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL;
1968- if (poll_fds.items[1].revents & inp_flags != 0) {
1969- // Read from PTY. Buffer is sized to N_TTY_BUF_SIZE (4096): the hard
1970- // kernel limit for the N_TTY line discipline. A larger buffer doesn't
1971- // help: each read() from a PTY master returns at most 4096 bytes
1972- // regardless of the userspace buffer size.
1973- var buf: [4096]u8 = undefined;
1974- const n_opt: ?usize = lib_posix.read(pty_fd, &buf) catch |err| blk: {
1975- if (err == error.WouldBlock) break :blk null;
1976- break :blk 0;
1977- };
1978-
1979- if (n_opt) |n| {
1980- if (n == 0) {
1981- // EOF: Shell exited
1982- std.log.info("shell exited pty_fd={d}", .{pty_fd});
1983- // Let the rest of this poll iteration complete so client
1984- // write buffers are flushed via the normal POLLOUT path.
1985- // On the next iteration, daemon.running will be false.
1986- daemon.running = false;
1987- } else {
1988- // Feed PTY output to terminal emulator for state tracking
1989- vt_stream.nextSlice(buf[0..n]);
1990- daemon.has_pty_output = true;
1991-
1992- // When no real terminal client has attached yet, respond to
1993- // terminal queries (e.g. DA1/DA2) on behalf of the terminal.
1994- // This prevents fish from waiting 10s for unanswered queries.
1995- // `has_terminal_client` is only set when a client sends .Init
1996- // (a real zmx attach), not when a `zmx run` tail-only client
1997- // connects.
1998- if (!daemon.has_terminal_client and
1999- daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX)
2000- {
2001- util.respondToDeviceAttributes(daemon.alloc, &daemon.pty_write_buf, buf[0..n]);
2002- }
2003-
2004- // In run mode, scan output for exit code marker. The marker
2005- // can straddle two PTY reads (more likely under a throttled
2006- // scheduler, e.g. containers), so prepend the tail carried
2007- // over from the previous read before searching.
2008- if (daemon.is_task_mode and daemon.task_exit_code == null) {
2009- var scan_buf: [marker_carry.len + buf.len]u8 = undefined;
2010- @memcpy(scan_buf[0..marker_carry_len], marker_carry[0..marker_carry_len]);
2011- @memcpy(scan_buf[marker_carry_len..][0..n], buf[0..n]);
2012- const scan_len = marker_carry_len + n;
2013-
2014- if (util.findTaskExitMarker(scan_buf[0..scan_len])) |exit_code| {
2015- daemon.task_exit_code = exit_code;
2016- daemon.task_ended_at = @intCast(std.Io.Timestamp.now(daemon.io, .real).nanoseconds);
2017-
2018- std.log.info("task completed exit_code={d}", .{exit_code});
2019-
2020- // Notify connected clients
2021- for (daemon.clients.items) |c| {
2022- ipc.appendMessage(daemon.alloc, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
2023- c.has_pending_output = true;
2024- }
2025- }
2026-
2027- marker_carry_len = @min(marker_carry.len, scan_len);
2028- @memcpy(
2029- marker_carry[0..marker_carry_len],
2030- scan_buf[scan_len - marker_carry_len .. scan_len],
2031- );
2032- }
2033-
2034- // Broadcast data to all clients.
2035- // Rewrite OSC 133;A to include redraw=0 so the outer terminal
2036- // does not clear prompt lines on resize (issue #111).
2037- const broadcast_data = util.rewritePromptRedraw(daemon.alloc, buf[0..n]) orelse buf[0..n];
2038- defer if (broadcast_data.ptr != buf[0..n].ptr) daemon.alloc.free(broadcast_data);
2039- for (daemon.clients.items) |client| {
2040- ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, broadcast_data) catch |err| {
2041- std.log.warn(
2042- "failed to buffer output for client err={s}",
2043- .{@errorName(err)},
2044- );
2045- continue;
2046- };
2047- client.has_pending_output = true;
2048- }
2049- }
2050- }
2051- }
2052-
2053- if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
2054- while (daemon.pty_write_buf.items.len > 0) {
2055- const n = lib_posix.write(pty_fd, daemon.pty_write_buf.items) catch |err| {
2056- if (err != error.WouldBlock) {
2057- std.log.warn("pty write failed: {s}", .{@errorName(err)});
2058- daemon.pty_write_buf.clearRetainingCapacity();
2059- }
2060- break;
2061- };
2062- if (n == 0) break;
2063- daemon.pty_write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
2064- }
2065- }
2066-
2067- var i: usize = daemon.clients.items.len;
2068- // Only iterate over clients that were present when poll_fds was constructed
2069- // poll_fds contains [server, pty, sig_pipe, client0, client1, ...]
2070- // So number of clients in poll_fds is poll_fds.items.len - 3
2071- const num_polled_clients = poll_fds.items.len - 3;
2072- if (i > num_polled_clients) {
2073- // If we have more clients than polled (i.e. we just accepted one), start from the
2074- // polled ones
2075- i = num_polled_clients;
2076- }
2077-
2078- clients_loop: while (i > 0) {
2079- i -= 1;
2080- const client = daemon.clients.items[i];
2081- const revents = poll_fds.items[i + 3].revents;
2082-
2083- if (revents & lib_posix.POLL.IN != 0) {
2084- const n = client.read_buf.read(client.socket_fd) catch |err| {
2085- if (err == error.WouldBlock) continue;
2086- std.log.debug(
2087- "client read err={s} fd={d}",
2088- .{ @errorName(err), client.socket_fd },
2089- );
2090- const last = daemon.closeClient(client, i, false);
2091- if (last) break :daemon_loop;
2092- continue;
2093- };
2094-
2095- if (n == 0) {
2096- // Client closed connection
2097- const last = daemon.closeClient(client, i, false);
2098- if (last) break :daemon_loop;
2099- continue;
2100- }
2101-
2102- while (client.read_buf.next()) |msg| {
2103- switch (msg.header.tag) {
2104- .Input => try daemon.handleInput(client, msg.payload),
2105- .Send => daemon.handleSend(msg.payload),
2106- .Output => try daemon.handleOutput(msg.payload, &vt_stream),
2107- .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
2108- .Switch => try daemon.handleSwitch(msg.payload),
2109- .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload),
2110- .Detach => {
2111- daemon.handleDetach(client, i);
2112- break :clients_loop;
2113- },
2114- .DetachAll => {
2115- daemon.handleDetachAll();
2116- break :clients_loop;
2117- },
2118- .Kill => {
2119- break :daemon_loop;
2120- },
2121- .Info => try daemon.handleInfo(client),
2122- .LabelGet => try daemon.handleLabelGet(client),
2123- .LabelSet => try daemon.handleLabelSet(client, msg.payload),
2124- .LabelClear => try daemon.handleLabelClear(client),
2125- .History => try daemon.handleHistory(client, &term, msg.payload),
2126- .Run => try daemon.handleRun(client, msg.payload),
2127- .Ack, .TaskComplete, .LabelData => {},
2128- .Write => try daemon.handleWrite(client, msg.payload),
2129- _ => std.log.warn(
2130- "ignoring unknown IPC tag={d}",
2131- .{@intFromEnum(msg.header.tag)},
2132- ),
2133- }
2134- }
2135- }
2136-
2137- if (revents & lib_posix.POLL.OUT != 0) {
2138- // Flush pending output buffers
2139- const n = lib_posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
2140- if (err == error.WouldBlock) break :blk 0;
2141- // Error on write, close client
2142- const last = daemon.closeClient(client, i, false);
2143- if (last) break :daemon_loop;
2144- continue;
2145- };
2146-
2147- if (n > 0) {
2148- client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
2149- }
2150-
2151- if (client.write_buf.items.len == 0) {
2152- client.has_pending_output = false;
2153- }
2154- }
2155-
2156- if (revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
2157- const last = daemon.closeClient(client, i, false);
2158- if (last) break :daemon_loop;
2159- }
2160- }
2161- }
2162-}
2163-
2164-fn wakeSignalPipe(_: std.os.linux.SIG, _: *const lib_posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
2165- const saved = std.c._errno().*;
2166- _ = std.c.write(sig_pipe[1], "x", 1);
2167- std.c._errno().* = saved;
2168-}
2169-
2170-// std.posix.poll retries EINTR internally, so SA_RESTART is moot -- neither
2171-// setting wakes the loop. The handler writes to sig_pipe instead; poll()
2172-// wakes on its read end.
2173-fn installWakeHandler(sig: u6) void {
2174- const act: lib_posix.Sigaction = .{
2175- .handler = .{ .sigaction = wakeSignalPipe },
2176- .mask = lib_posix.sigemptyset(),
2177- .flags = lib_posix.SA.SIGINFO,
2178- };
2179- lib_posix.sigaction(@as(lib_posix.SIG, @enumFromInt(sig)), &act, null);
2180-}
2181-
2182-fn ignoreSigpipe() void {
2183- const act: lib_posix.Sigaction = .{
2184- .handler = .{ .handler = lib_posix.SIG.IGN },
2185- .mask = lib_posix.sigemptyset(),
2186- .flags = 0,
2187- };
2188- lib_posix.sigaction(lib_posix.SIG.PIPE, &act, null);
2189-}
+4,
-1
1@@ -30,8 +30,8 @@ const pid_t = system.pid_t;
2 const lfs64_abi = native_os == .linux and builtin.link_libc and (builtin.abi.isGnu() or builtin.abi.isAndroid());
3 const uid_t = system.uid_t;
4 const mode_t = system.mode_t;
5-const socket_t = fd_t;
6 const FD_CLOEXEC = system.FD_CLOEXEC;
7+pub const socket_t = fd_t;
8 pub const SA = system.SA;
9 pub const fd_t = system.fd_t;
10 pub const O = system.O;
11@@ -52,6 +52,9 @@ pub const Sigaction = system.Sigaction;
12 pub const SIG = system.SIG;
13 pub const siginfo_t = system.siginfo_t;
14
15+// https://github.com/ziglang/zig/blob/738d2be9d6b6ef3ff3559130c05159ef53336224/lib/std/posix.zig#L3505
16+pub const O_NONBLOCK: usize = 1 << @bitOffsetOf(O, "NONBLOCK");
17+
18 pub fn getuid() uid_t {
19 return system.getuid();
20 }
+46,
-0
1@@ -0,0 +1,46 @@
2+const std = @import("std");
3+const lib_posix = @import("posix.zig");
4+
5+/// Self-pipe woken by signal handlers. std.posix.poll loops on .INTR internally
6+/// (PollError has no Interrupted member), so a signal that lands during poll()
7+/// never surfaces; the handler writes a byte here and poll() wakes on POLLIN.
8+pub var sig_pipe: [2]lib_posix.fd_t = .{ -1, -1 };
9+
10+pub fn wakeSignalPipe(_: std.os.linux.SIG, _: *const lib_posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
11+ const saved = std.c._errno().*;
12+ _ = std.c.write(sig_pipe[1], "x", 1);
13+ std.c._errno().* = saved;
14+}
15+
16+// std.posix.poll retries EINTR internally, so SA_RESTART is moot -- neither
17+// setting wakes the loop. The handler writes to sig_pipe instead; poll()
18+// wakes on its read end.
19+pub fn installWakeHandler(sig: u6) void {
20+ const act: lib_posix.Sigaction = .{
21+ .handler = .{ .sigaction = wakeSignalPipe },
22+ .mask = lib_posix.sigemptyset(),
23+ .flags = lib_posix.SA.SIGINFO,
24+ };
25+ lib_posix.sigaction(@as(lib_posix.SIG, @enumFromInt(sig)), &act, null);
26+}
27+
28+pub fn ignoreSigpipe() void {
29+ const act: lib_posix.Sigaction = .{
30+ .handler = .{ .handler = lib_posix.SIG.IGN },
31+ .mask = lib_posix.sigemptyset(),
32+ .flags = 0,
33+ };
34+ lib_posix.sigaction(lib_posix.SIG.PIPE, &act, null);
35+}
36+
37+pub fn openSignalPipe() !void {
38+ sig_pipe = try lib_posix.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true });
39+}
40+
41+pub fn drainSignalPipe() void {
42+ var b: [16]u8 = undefined;
43+ while (true) {
44+ const n = lib_posix.read(sig_pipe[0], &b) catch return;
45+ if (n == 0) return;
46+ }
47+}
+39,
-1
1@@ -28,6 +28,44 @@ pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
2 return full;
3 }
4
5+pub fn resolveSessionOrEnv(alloc: std.mem.Allocator, io: std.Io, session_name: ?[]const u8) ![]const u8 {
6+ const sesh_env = getSeshNameFromEnv();
7+ const raw = if (session_name) |name|
8+ if (std.mem.eql(u8, name, ".")) blk: {
9+ if (sesh_env.len > 0) break :blk sesh_env;
10+ var buf: [4096]u8 = undefined;
11+ var w = std.Io.File.stderr().writer(io, &buf);
12+ w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
13+ w.interface.flush() catch {};
14+ return error.SessionNameRequired;
15+ } else name
16+ else if (sesh_env.len > 0)
17+ sesh_env
18+ else {
19+ return error.SessionNameRequired;
20+ };
21+ return getSeshName(alloc, raw);
22+}
23+
24+pub const SessionMatch = struct {
25+ name: []const u8,
26+ is_prefix: bool,
27+
28+ pub fn matches(self: SessionMatch, session_name: []const u8) bool {
29+ if (self.is_prefix) return std.mem.startsWith(u8, session_name, self.name);
30+ return std.mem.eql(u8, session_name, self.name);
31+ }
32+};
33+
34+pub fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
35+ if (raw.len > 0 and raw[raw.len - 1] == '*') {
36+ const name = try getSeshName(alloc, raw[0 .. raw.len - 1]);
37+ return .{ .name = name, .is_prefix = true };
38+ }
39+ const name = try getSeshName(alloc, raw);
40+ return .{ .name = name, .is_prefix = false };
41+}
42+
43 pub fn sessionConnect(sesh: []const u8) !i32 {
44 var unix_addr = try lib_posix.initUnix(sesh);
45 const socket_fd = try lib_posix.socket(lib_posix.AF.UNIX, lib_posix.SOCK.STREAM | lib_posix.SOCK.CLOEXEC, 0);
46@@ -56,7 +94,7 @@ pub fn sessionExists(io: std.Io, dir: std.Io.Dir, name: []const u8) !bool {
47 return true;
48 }
49
50-pub fn createSocket(sesh: []const u8) !i32 {
51+pub fn createSocket(sesh: []const u8) !lib_posix.socket_t {
52 // AF.UNIX: Unix domain socket for local IPC with client processes
53 // SOCK.STREAM: Reliable, bidirectional communication
54 // SOCK.NONBLOCK: Set socket to non-blocking
+4,
-0
1@@ -4,4 +4,8 @@ comptime {
2 _ = @import("socket.zig");
3 _ = @import("ipc.zig");
4 _ = @import("label.zig");
5+ _ = @import("signal.zig");
6+ _ = @import("loop.zig");
7+ _ = @import("cfg.zig");
8+ _ = @import("daemonize.zig");
9 }