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