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