main zmx / src / daemonize.zig
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}