Eric Bower
·
2026-10-23
1const std = @import("std");
2const lib_posix = @import("posix.zig");
3const Cfg = @import("cfg.zig");
4const socket = @import("socket.zig");
5const ipc = @import("ipc.zig");
6const assert = std.debug.assert;
7const log = @import("log.zig");
8const cross = @import("cross");
9
10const Cmd = struct {
11 file: [*:0]const u8,
12 argv_ptr: [*:null]const ?[*:0]const u8,
13};
14
15pub fn createCmdZ(def_shell: []const u8, is_task_mode: bool, command: ?[]const []const u8) !Cmd {
16 const gpa = std.heap.c_allocator;
17
18 if (command) |cmd_args| {
19 if (cmd_args.len > 0) {
20 const argv = try gpa.allocSentinel(?[*:0]const u8, cmd_args.len, null);
21 for (cmd_args, 0..) |arg, i| {
22 const s = try gpa.dupeSentinel(u8, arg, 0);
23 argv[i] = s.ptr;
24 }
25 return .{
26 .file = argv[0].?,
27 .argv_ptr = argv.ptr,
28 };
29 }
30 }
31
32 const shell: [:0]const u8 = if (is_task_mode)
33 "bash"
34 else
35 try gpa.dupeSentinel(u8, def_shell, 0);
36
37 // Use "-shellname" as argv[0] to signal login shell (traditional method)
38 const login_shell = try std.fmt.allocPrintSentinel(
39 gpa,
40 "-{s}",
41 .{std.fs.path.basename(shell)},
42 0,
43 );
44 const argv = try gpa.allocSentinel(?[*:0]const u8, 1, null);
45 argv[0] = login_shell.ptr;
46
47 return .{
48 .file = shell,
49 .argv_ptr = argv.ptr,
50 };
51}
52
53/// Runs in the forked child. Either execs or returns an error (caller
54/// must exit on error -- returning would fall through to parent code).
55fn exec(sesh_name: []const u8, cmd: Cmd) !noreturn {
56 const gpa = std.heap.c_allocator;
57
58 // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
59 // exec. Restore the default so the shell and its children behave
60 // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
61 const dfl: lib_posix.Sigaction = .{
62 .handler = .{ .handler = lib_posix.SIG.DFL },
63 .mask = lib_posix.sigemptyset(),
64 .flags = 0,
65 };
66 lib_posix.sigaction(lib_posix.SIG.PIPE, &dfl, null);
67
68 const session_env = try std.fmt.allocPrintSentinel(
69 gpa,
70 "ZMX_SESSION={s}",
71 .{sesh_name},
72 0,
73 );
74 _ = cross.putenv(session_env.ptr);
75
76 if (cross.getenv("TERM")) |term_env| {
77 if (std.mem.eql(u8, std.mem.span(term_env), "dumb")) {
78 _ = cross.putenv(@constCast("TERM=xterm-256color"));
79 }
80 } else {
81 _ = cross.putenv(@constCast("TERM=xterm-256color"));
82 }
83
84 const err = lib_posix.execvpeZ(cmd.file, cmd.argv_ptr, std.c.environ);
85 std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd.file, @errorName(err) });
86 lib_posix.exit(1);
87}
88
89pub const PtyInfo = struct {
90 master_fd: c_int = undefined,
91 pid: c_int = undefined,
92};
93
94/// spawnPty runs forkpty() and executes the shell or shell command the user
95/// provides.
96///
97/// This is the second fork in the double-fork technique explained in the
98/// daemonize() comment.
99pub fn spawnPty(sesh_name: []const u8, cmd: Cmd, size: ipc.Resize) !PtyInfo {
100 var ws: cross.struct_winsize = .{
101 .ws_row = size.rows,
102 .ws_col = size.cols,
103 .ws_xpixel = size.xpixel,
104 .ws_ypixel = size.ypixel,
105 };
106
107 var master_fd: c_int = undefined;
108 const pid = cross.forkpty(&master_fd, null, null, &ws);
109 if (pid < 0) {
110 return error.ForkPtyFailed;
111 }
112
113 if (pid == 0) { // child pid code path
114 // In the forked child, ANY error must exit rather than propagate:
115 // a returned error falls through to the parent code path below,
116 // running a second daemon on the same socket (or worse, hitting
117 // errdefers that delete the parent's socket file).
118 exec(sesh_name, cmd) catch |err| {
119 std.log.err("child setup failed: {s}", .{@errorName(err)});
120 lib_posix.exit(1);
121 };
122 unreachable; // exec() either execs or exits, never returns ok
123 }
124 // master pid code path
125 std.log.info("pty spawned session={s} pid={d}", .{ sesh_name, pid });
126
127 // make pty non-blocking
128 const flags = try lib_posix.fcntl(master_fd, lib_posix.F.GETFL, 0);
129 _ = try lib_posix.fcntl(master_fd, lib_posix.F.SETFL, flags | lib_posix.O_NONBLOCK);
130
131 return .{
132 .master_fd = master_fd,
133 .pid = pid,
134 };
135}
136
137/// daemonize is the first fork in a double-fork technique to create a
138/// completely disconnected session (container of process groups).
139///
140/// When launching a daemon, you normally set the child process of the fork to
141/// be the session leader via setsid() which creates a new session that removes
142/// the current controlling terminal. This is important because we don't want a
143/// controlling terminal for our daemon or else it could receive signals to
144/// shutdown when the controlling terminal closes.
145///
146/// However, if the first fork's child process is also the daemon process, then
147/// it's technically possible for the daemon to open a terminal device (e.g.
148/// open("/dev/console", O_RDWR)) and then it would acquire a controlling
149/// terminal! A controlling terminal would expose the daemon to
150/// terminal-generated signals (e.g. SIGINT) or SIGHUP from terminal disconnect
151/// which could kill the daemon.
152///
153/// The first fork produces a child guaranteed not to be a group leader, so
154/// setsid() will succeed. By forking a second time, the grandchild process
155/// (the daemon) is not the session leader. Per POSIX, only a process that is
156/// the session leader can acquire a controlling terminal.
157///
158/// Apparently this is "a bit paranoid" and on Linux it is arguable since a
159/// session leader only acquires a controlling terminal under
160/// implementation-defined conditions. But the double-fork is the portable way
161/// to guarantee the daemon can never acquire one, regardless of how a given
162/// POSIX implementation behaves. So we baked it into zmx.
163///
164/// PID=42 SID=10 PGID=10 ← original (PG leader, has tty)
165/// │
166/// │ fork #1
167/// ├──────────┐
168/// │ exit │ PID=55 SID=10 PGID=10 (not a PG leader)
169/// ✝ │
170/// │ setsid()
171/// ▼
172/// PID=55 SID=55 PGID=55 (session leader, no tty)
173/// │
174/// │ fork #2
175/// ├──────────┐
176/// │ exit │ PID=73 SID=55 PGID=55
177/// ✝ │ PID≠SID → can't get a tty
178/// ▼
179/// DAEMON ✓
180pub fn daemonize(sesh_name: []const u8, cmd: Cmd, keep_fds_open: []i32) !PtyInfo {
181 // creates the daemon
182 const pid = try lib_posix.fork();
183 assert(pid != -1);
184
185 if (pid > 0) { // parent (client)
186 // cannot use a passed-in io or alloc after a fork so we create what we need
187 // after the fork()
188 var threaded: std.Io.Threaded = .init_single_threaded;
189 defer threaded.deinit();
190 const io = threaded.io();
191 std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .real) catch unreachable;
192 return error.IsClientProc;
193 }
194
195 assert(pid == 0); // child (daemon's parent in double-fork)
196 // becomes the session leader and detaches process from its controlling terminal
197 _ = try lib_posix.setsid();
198
199 // Fetch terminal size before redirecting stdio FDs to /dev/null.
200 const term_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
201
202 // Redirect stdin/stdout/stderr to /dev/null. The daemon
203 // communicates via its unix socket, not stdio. Without
204 // this, any pipe on FDs 0-2 (e.g. from bats' `run`
205 // keyword) stays open for the daemon's lifetime, causing
206 // the caller to hang waiting for EOF.
207 {
208 const devnull = lib_posix.open(
209 "/dev/null",
210 .{ .ACCMODE = .RDWR },
211 0,
212 ) catch |err| {
213 std.log.warn("failed to open /dev/null: {s}", .{@errorName(err)});
214 return err;
215 };
216 inline for (.{ lib_posix.STDIN_FILENO, lib_posix.STDOUT_FILENO, lib_posix.STDERR_FILENO }) |fd| {
217 _ = lib_posix.dup2(devnull, fd) catch |err| {
218 std.log.warn("dup2 /dev/null -> {d}: {s}", .{ fd, @errorName(err) });
219 return err;
220 };
221 }
222 var found = false;
223 for (keep_fds_open) |fd| {
224 if (devnull == fd) found = true;
225 }
226 if (devnull > 2 and !found) lib_posix.close(devnull);
227 }
228
229 // Close file descriptors inherited from the parent that the
230 // daemon doesn't need. This prevents test harnesses (like
231 // bats) from hanging: they wait for their internal FDs (3+)
232 // to close before exiting.
233 //
234 // Skip any fds that the caller wants to keep open, e.g. server_sock_fd
235 // (needed for IPC) and dir.fd (needed to delete the socket file on
236 // shutdown).
237 {
238 var fd: i32 = 3;
239 while (fd < 64) : (fd += 1) {
240 var found = false;
241 for (keep_fds_open) |kfd| {
242 if (fd == kfd) found = true;
243 }
244 if (!found) _ = std.c.close(fd);
245 }
246 }
247
248 return spawnPty(sesh_name, cmd, term_size);
249}