Eric Bower
·
2025-12-20
main.zig
1const std = @import("std");
2const posix = std.posix;
3const builtin = @import("builtin");
4const build_options = @import("build_options");
5const ghostty_vt = @import("ghostty-vt");
6const ipc = @import("ipc.zig");
7const log = @import("log.zig");
8
9pub const version = build_options.version;
10pub const ghostty_version = build_options.ghostty_version;
11
12var log_system = log.LogSystem{};
13
14pub const std_options: std.Options = .{
15 .logFn = zmxLogFn,
16 .log_level = .debug,
17};
18
19fn zmxLogFn(
20 comptime level: std.log.Level,
21 comptime scope: @Type(.enum_literal),
22 comptime format: []const u8,
23 args: anytype,
24) void {
25 log_system.log(level, scope, format, args);
26}
27
28const c = switch (builtin.os.tag) {
29 .macos => @cImport({
30 @cInclude("sys/ioctl.h"); // ioctl and constants
31 @cInclude("termios.h");
32 @cInclude("stdlib.h");
33 @cInclude("unistd.h");
34 }),
35 .freebsd => @cImport({
36 @cInclude("termios.h"); // ioctl and constants
37 @cInclude("libutil.h"); // openpty()
38 @cInclude("stdlib.h");
39 @cInclude("unistd.h");
40 }),
41 else => @cImport({
42 @cInclude("sys/ioctl.h"); // ioctl and constants
43 @cInclude("pty.h");
44 @cInclude("stdlib.h");
45 @cInclude("unistd.h");
46 }),
47};
48
49// Manually declare forkpty for macOS since util.h is not available during cross-compilation
50const forkpty = if (builtin.os.tag == .macos)
51 struct {
52 extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
53 }.forkpty
54else
55 c.forkpty;
56
57var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
58
59const Client = struct {
60 alloc: std.mem.Allocator,
61 socket_fd: i32,
62 has_pending_output: bool = false,
63 read_buf: ipc.SocketBuffer,
64 write_buf: std.ArrayList(u8),
65
66 pub fn deinit(self: *Client) void {
67 posix.close(self.socket_fd);
68 self.read_buf.deinit();
69 self.write_buf.deinit(self.alloc);
70 }
71};
72
73const Cfg = struct {
74 socket_dir: []const u8,
75 log_dir: []const u8,
76 max_scrollback: usize = 10_000_000,
77
78 pub fn init(alloc: std.mem.Allocator) !Cfg {
79 const tmpdir = posix.getenv("TMPDIR") orelse "/tmp";
80 const uid = posix.getuid();
81
82 const socket_dir: []const u8 = if (posix.getenv("ZMX_DIR")) |zmxdir|
83 try alloc.dupe(u8, zmxdir)
84 else if (posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
85 try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
86 else
87 try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
88 errdefer alloc.free(socket_dir);
89
90 const log_dir = try std.fmt.allocPrint(alloc, "{s}/logs", .{socket_dir});
91 errdefer alloc.free(log_dir);
92
93 var cfg = Cfg{
94 .socket_dir = socket_dir,
95 .log_dir = log_dir,
96 };
97
98 try cfg.mkdir();
99
100 return cfg;
101 }
102
103 pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
104 if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
105 if (self.log_dir.len > 0) alloc.free(self.log_dir);
106 }
107
108 pub fn mkdir(self: *Cfg) !void {
109 posix.mkdirat(posix.AT.FDCWD, self.socket_dir, 0o750) catch |err| switch (err) {
110 error.PathAlreadyExists => {},
111 else => return err,
112 };
113
114 posix.mkdirat(posix.AT.FDCWD, self.log_dir, 0o750) catch |err| switch (err) {
115 error.PathAlreadyExists => {},
116 else => return err,
117 };
118 }
119};
120
121const Daemon = struct {
122 cfg: *Cfg,
123 alloc: std.mem.Allocator,
124 clients: std.ArrayList(*Client),
125 session_name: []const u8,
126 socket_path: []const u8,
127 running: bool,
128 pid: i32,
129 command: ?[]const []const u8 = null,
130 has_pty_output: bool = false,
131 has_had_client: bool = false,
132
133 pub fn deinit(self: *Daemon) void {
134 self.clients.deinit(self.alloc);
135 self.alloc.free(self.socket_path);
136 }
137
138 pub fn shutdown(self: *Daemon) void {
139 std.log.info("shutting down daemon session_name={s}", .{self.session_name});
140 self.running = false;
141
142 for (self.clients.items) |client| {
143 client.deinit();
144 self.alloc.destroy(client);
145 }
146 self.clients.clearRetainingCapacity();
147 }
148
149 pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool {
150 const fd = client.socket_fd;
151 client.deinit();
152 self.alloc.destroy(client);
153 _ = self.clients.orderedRemove(i);
154 std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
155 if (shutdown_on_last and self.clients.items.len == 0) {
156 self.shutdown();
157 return true;
158 }
159 return false;
160 }
161
162 pub fn handleInput(self: *Daemon, pty_fd: i32, payload: []const u8) !void {
163 _ = self;
164 if (payload.len > 0) {
165 _ = try posix.write(pty_fd, payload);
166 }
167 }
168
169 pub fn handleInit(
170 self: *Daemon,
171 client: *Client,
172 pty_fd: i32,
173 term: *ghostty_vt.Terminal,
174 payload: []const u8,
175 ) !void {
176 if (payload.len != @sizeOf(ipc.Resize)) return;
177
178 const resize = std.mem.bytesToValue(ipc.Resize, payload);
179
180 // Serialize terminal state BEFORE resize to capture correct cursor position.
181 // Resizing triggers reflow which can move the cursor, and the shell's
182 // SIGWINCH-triggered redraw will run after our snapshot is sent.
183 // Only serialize on re-attach (has_had_client), not first attach, to avoid
184 // interfering with shell initialization (DA1 queries, etc.)
185 if (self.has_pty_output and self.has_had_client) {
186 const cursor = &term.screens.active.cursor;
187 std.log.debug("cursor before serialize: x={d} y={d} pending_wrap={}", .{ cursor.x, cursor.y, cursor.pending_wrap });
188 if (serializeTerminalState(self.alloc, term)) |term_output| {
189 std.log.debug("serialize terminal state", .{});
190 defer self.alloc.free(term_output);
191 ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| {
192 std.log.warn("failed to buffer terminal state for client err={s}", .{@errorName(err)});
193 };
194 client.has_pending_output = true;
195 }
196 }
197
198 var ws: c.struct_winsize = .{
199 .ws_row = resize.rows,
200 .ws_col = resize.cols,
201 .ws_xpixel = 0,
202 .ws_ypixel = 0,
203 };
204 _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
205 try term.resize(self.alloc, resize.cols, resize.rows);
206
207 // Mark that we've had a client init, so subsequent clients get terminal state
208 self.has_had_client = true;
209
210 std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
211 }
212
213 pub fn handleResize(self: *Daemon, pty_fd: i32, term: *ghostty_vt.Terminal, payload: []const u8) !void {
214 if (payload.len != @sizeOf(ipc.Resize)) return;
215
216 const resize = std.mem.bytesToValue(ipc.Resize, payload);
217 var ws: c.struct_winsize = .{
218 .ws_row = resize.rows,
219 .ws_col = resize.cols,
220 .ws_xpixel = 0,
221 .ws_ypixel = 0,
222 };
223 _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
224 try term.resize(self.alloc, resize.cols, resize.rows);
225 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
226 }
227
228 pub fn handleDetach(self: *Daemon, client: *Client, i: usize) void {
229 std.log.info("client detach fd={d}", .{client.socket_fd});
230 _ = self.closeClient(client, i, false);
231 }
232
233 pub fn handleDetachAll(self: *Daemon) void {
234 std.log.info("detach all clients={d}", .{self.clients.items.len});
235 for (self.clients.items) |client_to_close| {
236 client_to_close.deinit();
237 self.alloc.destroy(client_to_close);
238 }
239 self.clients.clearRetainingCapacity();
240 }
241
242 pub fn handleKill(self: *Daemon) void {
243 std.log.info("kill received session={s}", .{self.session_name});
244 posix.kill(self.pid, posix.SIG.TERM) catch |err| {
245 std.log.warn("failed to send SIGTERM to pty child err={s}", .{@errorName(err)});
246 };
247 self.shutdown();
248 }
249
250 pub fn handleInfo(self: *Daemon, client: *Client) !void {
251 const clients_len = self.clients.items.len - 1;
252 const info = ipc.Info{
253 .clients_len = clients_len,
254 .pid = self.pid,
255 };
256 try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
257 client.has_pending_output = true;
258 }
259};
260
261pub fn main() !void {
262 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
263 const alloc = std.heap.c_allocator;
264
265 var args = try std.process.argsWithAllocator(alloc);
266 defer args.deinit();
267 _ = args.skip(); // skip program name
268
269 var cfg = try Cfg.init(alloc);
270 defer cfg.deinit(alloc);
271
272 const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
273 defer alloc.free(log_path);
274 try log_system.init(alloc, log_path);
275 defer log_system.deinit();
276
277 const cmd = args.next() orelse {
278 return list(&cfg);
279 };
280
281 if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) {
282 return printVersion();
283 } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
284 return help();
285 } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
286 return list(&cfg);
287 } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
288 return detachAll(&cfg);
289 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
290 const session_name = args.next() orelse {
291 return error.SessionNameRequired;
292 };
293 return kill(&cfg, session_name);
294 } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
295 const session_name = args.next() orelse {
296 return error.SessionNameRequired;
297 };
298
299 var command_args: std.ArrayList([]const u8) = .empty;
300 defer command_args.deinit(alloc);
301 while (args.next()) |arg| {
302 try command_args.append(alloc, arg);
303 }
304
305 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
306 var command: ?[][]const u8 = null;
307 if (command_args.items.len > 0) {
308 command = command_args.items;
309 }
310 var daemon = Daemon{
311 .running = true,
312 .cfg = &cfg,
313 .alloc = alloc,
314 .clients = clients,
315 .session_name = session_name,
316 .socket_path = undefined,
317 .pid = undefined,
318 .command = command,
319 };
320 daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
321 std.log.info("socket path={s}", .{daemon.socket_path});
322 return attach(&daemon);
323 } else {
324 return help();
325 }
326}
327
328fn printVersion() !void {
329 var buf: [256]u8 = undefined;
330 var w = std.fs.File.stdout().writer(&buf);
331 try w.interface.print("zmx {s}\nghostty-vt {s}\n", .{ version, ghostty_version });
332 try w.interface.flush();
333}
334
335fn help() !void {
336 const help_text =
337 \\zmx - session persistence for terminal processes
338 \\
339 \\Usage: zmx <command> [args]
340 \\
341 \\Commands:
342 \\ [a]ttach <name> [command...] Create or attach to a session
343 \\ [d]etach Detach all clients from current session (ctrl+b + d for current client)
344 \\ [l]ist List active sessions
345 \\ [k]ill <name> Kill a session and all attached clients
346 \\ [v]ersion Show version information
347 \\ [h]elp Show this help message
348 \\
349 ;
350 var buf: [4096]u8 = undefined;
351 var w = std.fs.File.stdout().writer(&buf);
352 try w.interface.print(help_text, .{});
353 try w.interface.flush();
354}
355
356const SessionEntry = struct {
357 name: []const u8,
358 pid: ?i32,
359 clients_len: ?usize,
360 is_error: bool,
361 error_name: ?[]const u8,
362
363 fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
364 return std.mem.order(u8, a.name, b.name) == .lt;
365 }
366};
367
368fn list(cfg: *Cfg) !void {
369 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
370 defer _ = gpa.deinit();
371 const alloc = gpa.allocator();
372
373 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
374 defer dir.close();
375 var iter = dir.iterate();
376 var buf: [4096]u8 = undefined;
377 var w = std.fs.File.stdout().writer(&buf);
378
379 var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 16);
380 defer {
381 for (sessions.items) |session| {
382 alloc.free(session.name);
383 }
384 sessions.deinit(alloc);
385 }
386
387 while (try iter.next()) |entry| {
388 const exists = sessionExists(dir, entry.name) catch continue;
389 if (exists) {
390 const name = try alloc.dupe(u8, entry.name);
391 errdefer alloc.free(name);
392
393 const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name);
394 defer alloc.free(socket_path);
395
396 const result = probeSession(alloc, socket_path) catch |err| {
397 try sessions.append(alloc, .{
398 .name = name,
399 .pid = null,
400 .clients_len = null,
401 .is_error = true,
402 .error_name = @errorName(err),
403 });
404 cleanupStaleSocket(dir, entry.name);
405 continue;
406 };
407 posix.close(result.fd);
408
409 try sessions.append(alloc, .{
410 .name = name,
411 .pid = result.info.pid,
412 .clients_len = result.info.clients_len,
413 .is_error = false,
414 .error_name = null,
415 });
416 }
417 }
418
419 if (sessions.items.len == 0) {
420 try w.interface.print("no sessions found in {s}\n", .{cfg.socket_dir});
421 try w.interface.flush();
422 return;
423 }
424
425 std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan);
426
427 for (sessions.items) |session| {
428 if (session.is_error) {
429 try w.interface.print("session_name={s}\tstatus={s}\t(cleaning up)\n", .{ session.name, session.error_name.? });
430 } else {
431 try w.interface.print("session_name={s}\tpid={d}\tclients={d}\n", .{ session.name, session.pid.?, session.clients_len.? });
432 }
433 try w.interface.flush();
434 }
435}
436
437fn detachAll(cfg: *Cfg) !void {
438 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
439 defer _ = gpa.deinit();
440 const alloc = gpa.allocator();
441 const session_name = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) {
442 error.EnvironmentVariableNotFound => {
443 std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
444 return;
445 },
446 else => return err,
447 };
448 defer alloc.free(session_name);
449
450 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
451 defer dir.close();
452
453 const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
454 defer alloc.free(socket_path);
455 const result = probeSession(alloc, socket_path) catch |err| {
456 std.log.err("session unresponsive: {s}", .{@errorName(err)});
457 cleanupStaleSocket(dir, session_name);
458 return;
459 };
460 defer posix.close(result.fd);
461 ipc.send(result.fd, .DetachAll, "") catch |err| switch (err) {
462 error.BrokenPipe, error.ConnectionResetByPeer => return,
463 else => return err,
464 };
465}
466
467fn kill(cfg: *Cfg, session_name: []const u8) !void {
468 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
469 defer _ = gpa.deinit();
470 const alloc = gpa.allocator();
471
472 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
473 defer dir.close();
474
475 const exists = try sessionExists(dir, session_name);
476 if (!exists) {
477 std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name});
478 return;
479 }
480
481 const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
482 defer alloc.free(socket_path);
483 const result = probeSession(alloc, socket_path) catch |err| {
484 std.log.err("session unresponsive: {s}", .{@errorName(err)});
485 cleanupStaleSocket(dir, session_name);
486 var buf: [4096]u8 = undefined;
487 var w = std.fs.File.stdout().writer(&buf);
488 w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
489 w.interface.flush() catch {};
490 return;
491 };
492 defer posix.close(result.fd);
493 ipc.send(result.fd, .Kill, "") catch |err| switch (err) {
494 error.BrokenPipe, error.ConnectionResetByPeer => return,
495 else => return err,
496 };
497
498 var buf: [4096]u8 = undefined;
499 var w = std.fs.File.stdout().writer(&buf);
500 try w.interface.print("killed session {s}\n", .{session_name});
501 try w.interface.flush();
502}
503
504fn attach(daemon: *Daemon) !void {
505 if (std.posix.getenv("ZMX_SESSION")) |_| {
506 return error.CannotAttachToSessionInSession;
507 }
508
509 var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
510 defer dir.close();
511
512 const exists = try sessionExists(dir, daemon.session_name);
513 var should_create = !exists;
514
515 if (exists) {
516 if (probeSession(daemon.alloc, daemon.socket_path)) |result| {
517 posix.close(result.fd);
518 if (daemon.command != null) {
519 std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name});
520 }
521 } else |_| {
522 cleanupStaleSocket(dir, daemon.session_name);
523 should_create = true;
524 }
525 }
526
527 if (should_create) {
528 std.log.info("creating session={s}", .{daemon.session_name});
529 const server_sock_fd = try createSocket(daemon.socket_path);
530
531 const pid = try posix.fork();
532 if (pid == 0) { // child
533 _ = try posix.setsid();
534
535 log_system.deinit();
536 const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name});
537 defer daemon.alloc.free(session_log_name);
538 const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name });
539 defer daemon.alloc.free(session_log_path);
540 try log_system.init(daemon.alloc, session_log_path);
541
542 errdefer {
543 posix.close(server_sock_fd);
544 dir.deleteFile(daemon.session_name) catch {};
545 }
546 const pty_fd = try spawnPty(daemon);
547 defer {
548 posix.close(pty_fd);
549 posix.close(server_sock_fd);
550 std.log.info("deleting socket file session_name={s}", .{daemon.session_name});
551 dir.deleteFile(daemon.session_name) catch |err| {
552 std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
553 };
554 }
555 try daemonLoop(daemon, server_sock_fd, pty_fd);
556 // Reap PTY child to prevent zombie
557 _ = posix.waitpid(daemon.pid, 0);
558 daemon.deinit();
559 return;
560 }
561 posix.close(server_sock_fd);
562 std.Thread.sleep(10 * std.time.ns_per_ms);
563 }
564
565 const client_sock = try sessionConnect(daemon.socket_path);
566 std.log.info("attached session={s}", .{daemon.session_name});
567 // this is typically used with tcsetattr() to modify terminal settings.
568 // - you first get the current settings with tcgetattr()
569 // - modify the desired attributes in the termios structure
570 // - then apply the changes with tcsetattr().
571 // This prevents unintended side effects by preserving other settings.
572 var orig_termios: c.termios = undefined;
573 _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
574
575 // restore stdin fd to its original state after exiting.
576 // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
577 defer {
578 _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios);
579 // Reset terminal modes on detach:
580 // - Mouse: 1000=basic, 1002=button-event, 1003=any-event, 1006=SGR extended
581 // - 2004=bracketed paste, 1004=focus events, 1049=alt screen
582 // - 25h=show cursor, 2J=clear screen, H=cursor home
583 const restore_seq = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" ++
584 "\x1b[?2004l\x1b[?1004l\x1b[?1049l" ++
585 "\x1b[?25h\x1b[2J\x1b[H";
586 _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {};
587 }
588
589 var raw_termios = orig_termios;
590 // set raw mode after successful connection.
591 // disables canonical mode (line buffering), input echoing, signal generation from
592 // control characters (like Ctrl+C), and flow control.
593 c.cfmakeraw(&raw_termios);
594
595 // Additional granular raw mode settings for precise control
596 // (matches what abduco and shpool do)
597 raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
598 // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
599 raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
600 raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
601 raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
602
603 _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
604
605 // Clear screen and move cursor to home before attaching
606 const clear_seq = "\x1b[2J\x1b[H";
607 _ = try posix.write(posix.STDOUT_FILENO, clear_seq);
608
609 try clientLoop(daemon.cfg, client_sock);
610}
611
612fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
613 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
614 const alloc = std.heap.c_allocator;
615 defer posix.close(client_sock_fd);
616
617 setupSigwinchHandler();
618
619 // Send init message with terminal size
620 const size = getTerminalSize(posix.STDOUT_FILENO);
621 ipc.send(client_sock_fd, .Init, std.mem.asBytes(&size)) catch {};
622
623 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2);
624 defer poll_fds.deinit(alloc);
625
626 var read_buf = try ipc.SocketBuffer.init(alloc);
627 defer read_buf.deinit();
628
629 var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
630 defer stdout_buf.deinit(alloc);
631
632 const stdin_fd = posix.STDIN_FILENO;
633
634 // Prefix key state for ctrl+b + <key> bindings
635 var prefix_active = false;
636
637 // Make stdin non-blocking
638 const flags = try posix.fcntl(stdin_fd, posix.F.GETFL, 0);
639 _ = try posix.fcntl(stdin_fd, posix.F.SETFL, flags | posix.SOCK.NONBLOCK);
640
641 while (true) {
642 // Check for pending SIGWINCH
643 if (sigwinch_received.swap(false, .acq_rel)) {
644 const next_size = getTerminalSize(posix.STDOUT_FILENO);
645 ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&next_size)) catch |err| switch (err) {
646 error.BrokenPipe, error.ConnectionResetByPeer => return,
647 else => return err,
648 };
649 }
650
651 poll_fds.clearRetainingCapacity();
652
653 try poll_fds.append(alloc, .{
654 .fd = stdin_fd,
655 .events = posix.POLL.IN,
656 .revents = 0,
657 });
658
659 try poll_fds.append(alloc, .{
660 .fd = client_sock_fd,
661 .events = posix.POLL.IN,
662 .revents = 0,
663 });
664
665 if (stdout_buf.items.len > 0) {
666 try poll_fds.append(alloc, .{
667 .fd = posix.STDOUT_FILENO,
668 .events = posix.POLL.OUT,
669 .revents = 0,
670 });
671 }
672
673 _ = posix.poll(poll_fds.items, -1) catch |err| {
674 if (err == error.Interrupted) continue; // EINTR from signal, loop again
675 return err;
676 };
677
678 // Handle stdin -> socket (Input)
679 if (poll_fds.items[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
680 var buf: [4096]u8 = undefined;
681 const n_opt: ?usize = posix.read(stdin_fd, &buf) catch |err| blk: {
682 if (err == error.WouldBlock) break :blk null;
683 return err;
684 };
685
686 if (n_opt) |n| {
687 if (n > 0) {
688 // Check for Kitty keyboard protocol sequences first
689 switch (parseStdinInput(buf[0..n], &prefix_active)) {
690 .detach => {
691 ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
692 error.BrokenPipe, error.ConnectionResetByPeer => return,
693 else => return err,
694 };
695 continue;
696 },
697 .send => |data| {
698 ipc.send(client_sock_fd, .Input, data) catch |err| switch (err) {
699 error.BrokenPipe, error.ConnectionResetByPeer => return,
700 else => return err,
701 };
702 continue;
703 },
704 .activate_prefix => continue,
705 .none => {},
706 }
707
708 // Process byte-by-byte for non-Kitty input
709 var i: usize = 0;
710 while (i < n) : (i += 1) {
711 const action = parseStdinByte(buf[i], prefix_active);
712 switch (action) {
713 .detach => {
714 ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
715 error.BrokenPipe, error.ConnectionResetByPeer => return,
716 else => return err,
717 };
718 prefix_active = false;
719 },
720 .send => |data| {
721 ipc.send(client_sock_fd, .Input, data) catch |err| switch (err) {
722 error.BrokenPipe, error.ConnectionResetByPeer => return,
723 else => return err,
724 };
725 prefix_active = false;
726 },
727 .activate_prefix => {
728 prefix_active = true;
729 },
730 .none => {
731 if (prefix_active) {
732 // Unknown prefix command, forward both ctrl+b and this key
733 ipc.send(client_sock_fd, .Input, &[_]u8{0x02}) catch |err| switch (err) {
734 error.BrokenPipe, error.ConnectionResetByPeer => return,
735 else => return err,
736 };
737 ipc.send(client_sock_fd, .Input, buf[i .. i + 1]) catch |err| switch (err) {
738 error.BrokenPipe, error.ConnectionResetByPeer => return,
739 else => return err,
740 };
741 prefix_active = false;
742 } else {
743 ipc.send(client_sock_fd, .Input, buf[i .. i + 1]) catch |err| switch (err) {
744 error.BrokenPipe, error.ConnectionResetByPeer => return,
745 else => return err,
746 };
747 }
748 },
749 }
750 }
751 } else {
752 // EOF on stdin
753 return;
754 }
755 }
756 }
757
758 // Handle socket -> stdout (Output)
759 if (poll_fds.items[1].revents & posix.POLL.IN != 0) {
760 const n = read_buf.read(client_sock_fd) catch |err| {
761 if (err == error.WouldBlock) continue;
762 if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
763 return;
764 }
765 std.log.err("daemon read err={s}", .{@errorName(err)});
766 return err;
767 };
768 if (n == 0) {
769 return; // Server closed connection
770 }
771
772 while (read_buf.next()) |msg| {
773 switch (msg.header.tag) {
774 .Output => {
775 if (msg.payload.len > 0) {
776 try stdout_buf.appendSlice(alloc, msg.payload);
777 }
778 },
779 else => {},
780 }
781 }
782 }
783
784 if (stdout_buf.items.len > 0) {
785 const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
786 if (err == error.WouldBlock) break :blk 0;
787 return err;
788 };
789 if (n > 0) {
790 try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
791 }
792 }
793
794 if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
795 return;
796 }
797 }
798}
799
800fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
801 std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
802 var should_exit = false;
803 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
804 defer poll_fds.deinit(daemon.alloc);
805
806 const init_size = getTerminalSize(pty_fd);
807 var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
808 .cols = init_size.cols,
809 .rows = init_size.rows,
810 .max_scrollback = daemon.cfg.max_scrollback,
811 });
812 defer term.deinit(daemon.alloc);
813 var vt_stream = term.vtStream();
814 defer vt_stream.deinit();
815
816 while (!should_exit and daemon.running) {
817 poll_fds.clearRetainingCapacity();
818
819 try poll_fds.append(daemon.alloc, .{
820 .fd = server_sock_fd,
821 .events = posix.POLL.IN,
822 .revents = 0,
823 });
824
825 try poll_fds.append(daemon.alloc, .{
826 .fd = pty_fd,
827 .events = posix.POLL.IN,
828 .revents = 0,
829 });
830
831 for (daemon.clients.items) |client| {
832 var events: i16 = posix.POLL.IN;
833 if (client.has_pending_output) {
834 events |= posix.POLL.OUT;
835 }
836 try poll_fds.append(daemon.alloc, .{
837 .fd = client.socket_fd,
838 .events = events,
839 .revents = 0,
840 });
841 }
842
843 _ = posix.poll(poll_fds.items, -1) catch |err| {
844 return err;
845 };
846
847 if (poll_fds.items[0].revents & (posix.POLL.ERR | posix.POLL.HUP | posix.POLL.NVAL) != 0) {
848 std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
849 should_exit = true;
850 } else if (poll_fds.items[0].revents & posix.POLL.IN != 0) {
851 const client_fd = try posix.accept(server_sock_fd, null, null, posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC);
852 const client = try daemon.alloc.create(Client);
853 client.* = Client{
854 .alloc = daemon.alloc,
855 .socket_fd = client_fd,
856 .read_buf = try ipc.SocketBuffer.init(daemon.alloc),
857 .write_buf = undefined,
858 };
859 client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096);
860 try daemon.clients.append(daemon.alloc, client);
861 std.log.info("client connected fd={d} total={d}", .{ client_fd, daemon.clients.items.len });
862 }
863
864 if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
865 // Read from PTY
866 var buf: [4096]u8 = undefined;
867 const n_opt: ?usize = posix.read(pty_fd, &buf) catch |err| blk: {
868 if (err == error.WouldBlock) break :blk null;
869 break :blk 0;
870 };
871
872 if (n_opt) |n| {
873 if (n == 0) {
874 // EOF: Shell exited
875 std.log.info("shell exited pty_fd={d}", .{pty_fd});
876 should_exit = true;
877 } else {
878 // Feed PTY output to terminal emulator for state tracking
879 try vt_stream.nextSlice(buf[0..n]);
880 daemon.has_pty_output = true;
881
882 // Broadcast data to all clients
883 for (daemon.clients.items) |client| {
884 ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
885 std.log.warn("failed to buffer output for client err={s}", .{@errorName(err)});
886 continue;
887 };
888 client.has_pending_output = true;
889 }
890 }
891 }
892 }
893
894 var i: usize = daemon.clients.items.len;
895 // Only iterate over clients that were present when poll_fds was constructed
896 // poll_fds contains [server, pty, client0, client1, ...]
897 // So number of clients in poll_fds is poll_fds.items.len - 2
898 const num_polled_clients = poll_fds.items.len - 2;
899 if (i > num_polled_clients) {
900 // If we have more clients than polled (i.e. we just accepted one), start from the polled ones
901 i = num_polled_clients;
902 }
903
904 clients_loop: while (i > 0) {
905 i -= 1;
906 const client = daemon.clients.items[i];
907 const revents = poll_fds.items[i + 2].revents;
908
909 if (revents & posix.POLL.IN != 0) {
910 const n = client.read_buf.read(client.socket_fd) catch |err| {
911 if (err == error.WouldBlock) continue;
912 std.log.debug("client read err={s} fd={d}", .{ @errorName(err), client.socket_fd });
913 const last = daemon.closeClient(client, i, false);
914 if (last) should_exit = true;
915 continue;
916 };
917
918 if (n == 0) {
919 // Client closed connection
920 const last = daemon.closeClient(client, i, false);
921 if (last) should_exit = true;
922 continue;
923 }
924
925 while (client.read_buf.next()) |msg| {
926 switch (msg.header.tag) {
927 .Input => try daemon.handleInput(pty_fd, msg.payload),
928 .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
929 .Resize => try daemon.handleResize(pty_fd, &term, msg.payload),
930 .Detach => {
931 daemon.handleDetach(client, i);
932 break :clients_loop;
933 },
934 .DetachAll => {
935 daemon.handleDetachAll();
936 break :clients_loop;
937 },
938 .Kill => {
939 daemon.handleKill();
940 should_exit = true;
941 break :clients_loop;
942 },
943 .Info => try daemon.handleInfo(client),
944 .Output => {},
945 }
946 }
947 }
948
949 if (revents & posix.POLL.OUT != 0) {
950 // Flush pending output buffers
951 const n = posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
952 if (err == error.WouldBlock) break :blk 0;
953 // Error on write, close client
954 const last = daemon.closeClient(client, i, false);
955 if (last) should_exit = true;
956 continue;
957 };
958
959 if (n > 0) {
960 client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
961 }
962
963 if (client.write_buf.items.len == 0) {
964 client.has_pending_output = false;
965 }
966 }
967
968 if (revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
969 const last = daemon.closeClient(client, i, false);
970 if (last) should_exit = true;
971 }
972 }
973 }
974}
975
976fn spawnPty(daemon: *Daemon) !c_int {
977 const size = getTerminalSize(posix.STDOUT_FILENO);
978 var ws: c.struct_winsize = .{
979 .ws_row = size.rows,
980 .ws_col = size.cols,
981 .ws_xpixel = 0,
982 .ws_ypixel = 0,
983 };
984
985 var master_fd: c_int = undefined;
986 const pid = forkpty(&master_fd, null, null, &ws);
987 if (pid < 0) {
988 return error.ForkPtyFailed;
989 }
990
991 if (pid == 0) { // child pid code path
992 const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
993 _ = c.putenv(@ptrCast(session_env.ptr));
994
995 if (daemon.command) |cmd_args| {
996 const alloc = std.heap.c_allocator;
997 var argv_buf: [64:null]?[*:0]const u8 = undefined;
998 for (cmd_args, 0..) |arg, i| {
999 argv_buf[i] = alloc.dupeZ(u8, arg) catch {
1000 std.posix.exit(1);
1001 };
1002 }
1003 argv_buf[cmd_args.len] = null;
1004 const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
1005 const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
1006 std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
1007 std.posix.exit(1);
1008 } else {
1009 const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
1010 const argv = [_:null]?[*:0]const u8{ shell, null };
1011 const err = std.posix.execveZ(shell, &argv, std.c.environ);
1012 std.log.err("execve failed: err={s}", .{@errorName(err)});
1013 std.posix.exit(1);
1014 }
1015 }
1016 // master pid code path
1017 daemon.pid = pid;
1018 std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid });
1019
1020 // make pty non-blocking
1021 const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
1022 _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
1023 return master_fd;
1024}
1025
1026fn sessionConnect(fname: []const u8) !i32 {
1027 var unix_addr = try std.net.Address.initUnix(fname);
1028 const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
1029 errdefer posix.close(socket_fd);
1030 try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
1031 return socket_fd;
1032}
1033
1034const SessionProbeError = error{
1035 Timeout,
1036 ConnectionRefused,
1037 Unexpected,
1038};
1039
1040const SessionProbeResult = struct {
1041 fd: i32,
1042 info: ipc.Info,
1043};
1044
1045fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
1046 const timeout_ms = 1000;
1047 const fd = sessionConnect(socket_path) catch |err| switch (err) {
1048 error.ConnectionRefused => return error.ConnectionRefused,
1049 else => return error.Unexpected,
1050 };
1051 errdefer posix.close(fd);
1052
1053 ipc.send(fd, .Info, "") catch return error.Unexpected;
1054
1055 var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
1056 const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
1057 if (poll_result == 0) {
1058 return error.Timeout;
1059 }
1060
1061 var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected;
1062 defer sb.deinit();
1063
1064 const n = sb.read(fd) catch return error.Unexpected;
1065 if (n == 0) return error.Unexpected;
1066
1067 while (sb.next()) |msg| {
1068 if (msg.header.tag == .Info) {
1069 if (msg.payload.len == @sizeOf(ipc.Info)) {
1070 return .{
1071 .fd = fd,
1072 .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]),
1073 };
1074 }
1075 }
1076 }
1077 return error.Unexpected;
1078}
1079
1080const InputAction = union(enum) {
1081 send: []const u8,
1082 detach,
1083 activate_prefix,
1084 none,
1085};
1086
1087fn parseStdinByte(byte: u8, prefix_active: bool) InputAction {
1088 if (byte == 0x1C) { // Ctrl+\ (File Separator)
1089 return .detach;
1090 } else if (byte == 0x02) { // Ctrl+B
1091 if (prefix_active) {
1092 return .{ .send = &[_]u8{0x02} };
1093 } else {
1094 return .activate_prefix;
1095 }
1096 } else if (prefix_active) {
1097 if (byte == 'd') {
1098 return .detach;
1099 } else {
1100 return .none; // Unknown prefix command - caller handles forwarding
1101 }
1102 }
1103 return .none; // Regular byte - caller handles forwarding
1104}
1105
1106fn parseStdinInput(buf: []const u8, prefix_active: *bool) InputAction {
1107 // Check for Kitty keyboard protocol escape sequences first
1108 if (isKittyCtrlBackslash(buf)) {
1109 prefix_active.* = false;
1110 return .detach;
1111 }
1112
1113 if (isKittyCtrlB(buf)) {
1114 prefix_active.* = true;
1115 return .activate_prefix;
1116 }
1117
1118 // Handle prefix mode for Kitty 'd' key
1119 if (prefix_active.* and isKittyKey(buf, 'd')) {
1120 prefix_active.* = false;
1121 return .detach;
1122 }
1123
1124 return .none; // Not a special sequence
1125}
1126
1127fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
1128 std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
1129 dir.deleteFile(session_name) catch |err| {
1130 std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
1131 };
1132}
1133
1134fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
1135 const stat = dir.statFile(name) catch |err| switch (err) {
1136 error.FileNotFound => return false,
1137 else => return err,
1138 };
1139 if (stat.kind != .unix_domain_socket) {
1140 return error.FileNotUnixSocket;
1141 }
1142 return true;
1143}
1144
1145fn createSocket(fname: []const u8) !i32 {
1146 // AF.UNIX: Unix domain socket for local IPC with client processes
1147 // SOCK.STREAM: Reliable, bidirectional communication
1148 // SOCK.NONBLOCK: Set socket to non-blocking
1149 const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
1150 errdefer posix.close(fd);
1151
1152 var unix_addr = try std.net.Address.initUnix(fname);
1153 try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
1154 try posix.listen(fd, 128);
1155 return fd;
1156}
1157
1158pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
1159 const dir = socket_dir;
1160 const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
1161 @memcpy(fname[0..dir.len], dir);
1162 @memcpy(fname[dir.len .. dir.len + 1], "/");
1163 @memcpy(fname[dir.len + 1 ..], session_name);
1164 return fname;
1165}
1166
1167fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
1168 sigwinch_received.store(true, .release);
1169}
1170
1171fn setupSigwinchHandler() void {
1172 const act: posix.Sigaction = .{
1173 .handler = .{ .sigaction = handleSigwinch },
1174 .mask = posix.sigemptyset(),
1175 .flags = posix.SA.SIGINFO,
1176 };
1177 posix.sigaction(posix.SIG.WINCH, &act, null);
1178}
1179
1180fn getTerminalSize(fd: i32) ipc.Resize {
1181 var ws: c.struct_winsize = undefined;
1182 if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
1183 return .{ .rows = ws.ws_row, .cols = ws.ws_col };
1184 }
1185 return .{ .rows = 24, .cols = 80 };
1186}
1187
1188/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
1189/// Common sequences: \e[92;5u (basic), \e[92;133u (with event flags)
1190fn isKittyCtrlBackslash(buf: []const u8) bool {
1191 return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
1192 std.mem.indexOf(u8, buf, "\x1b[92;133u") != null;
1193}
1194
1195test "isKittyCtrlBackslash" {
1196 try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
1197 try std.testing.expect(isKittyCtrlBackslash("\x1b[92;133u"));
1198 try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
1199 try std.testing.expect(!isKittyCtrlBackslash("\x1b[93;5u"));
1200 try std.testing.expect(!isKittyCtrlBackslash("garbage"));
1201}
1202
1203fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
1204 var builder: std.Io.Writer.Allocating = .init(alloc);
1205 defer builder.deinit();
1206
1207 var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
1208 term_formatter.content = .{ .selection = null };
1209 term_formatter.extra = .{
1210 .palette = false,
1211 .modes = true,
1212 .scrolling_region = true,
1213 .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
1214 .pwd = true,
1215 .keyboard = true,
1216 .screen = .all,
1217 };
1218
1219 term_formatter.format(&builder.writer) catch |err| {
1220 std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
1221 return null;
1222 };
1223
1224 const output = builder.writer.buffered();
1225 if (output.len == 0) return null;
1226
1227 return alloc.dupe(u8, output) catch |err| {
1228 std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
1229 return null;
1230 };
1231}
1232
1233fn isKittyCtrlB(buf: []const u8) bool {
1234 return std.mem.indexOf(u8, buf, "\x1b[98;5u") != null or
1235 std.mem.indexOf(u8, buf, "\x1b[98;133u") != null;
1236}
1237
1238test "isKittyCtrlB" {
1239 try std.testing.expect(isKittyCtrlB("\x1b[98;5u"));
1240 try std.testing.expect(isKittyCtrlB("\x1b[98;133u"));
1241 try std.testing.expect(!isKittyCtrlB("\x1b[98;1u"));
1242 try std.testing.expect(!isKittyCtrlB("\x1b[99;5u"));
1243 try std.testing.expect(!isKittyCtrlB("garbage"));
1244}
1245
1246fn isKittyKey(buf: []const u8, key: u8) bool {
1247 var expected: [16]u8 = undefined;
1248 const seq = std.fmt.bufPrint(&expected, "\x1b[{d}u", .{key}) catch return false;
1249
1250 return std.mem.indexOf(u8, buf, seq) != null;
1251}
1252
1253test "isKittyKey" {
1254 try std.testing.expect(isKittyKey("\x1b[100u", 'd'));
1255 try std.testing.expect(!isKittyKey("\x1b[100;5u", 'd'));
1256 try std.testing.expect(!isKittyKey("\x1b[101u", 'd'));
1257 try std.testing.expect(!isKittyKey("d", 'd'));
1258}