repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

commit
76cec40
parent
11a12cc
author
Ian Tay
date
2026-03-08 12:55:37 -0400 EDT
fix(pty): isolate forked child from parent code path and heap-alloc argv

Two issues in spawnPty's child (pid==0) branch:

1. `try` on allocPrint/bufPrintZ could propagate an error past the
   if-block, causing the forked child to fall through to the parent
   code path — running a second daemon on the same socket, or hitting
   errdefers that delete the parent's socket file. The bufPrintZ case
   is triggerable via a long SHELL basename.

2. The fixed-size argv_buf[64] overflowed with >63 CLI arguments.

Both are fixed by extracting child setup into execChild() which returns
!noreturn (exec or error), with the caller exiting on any error. The
argv array is now heap-allocated to the exact size needed.
1 files changed,  +50, -25
M src/main.zig
+50, -25
 1@@ -294,6 +294,48 @@ const Daemon = struct {
 2         return false;
 3     }
 4 
 5+    /// Runs in the forked child. Either execs or returns an error (caller
 6+    /// must exit on error -- returning would fall through to parent code).
 7+    fn execChild(self: *Daemon) !noreturn {
 8+        const alloc = std.heap.c_allocator;
 9+
10+        // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
11+        // exec. Restore the default so the shell and its children behave
12+        // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
13+        const dfl: posix.Sigaction = .{
14+            .handler = .{ .handler = posix.SIG.DFL },
15+            .mask = posix.sigemptyset(),
16+            .flags = 0,
17+        };
18+        posix.sigaction(posix.SIG.PIPE, &dfl, null);
19+
20+        const session_env = try std.fmt.allocPrintSentinel(
21+            alloc,
22+            "ZMX_SESSION={s}",
23+            .{self.session_name},
24+            0,
25+        );
26+        _ = cross.c.putenv(session_env.ptr);
27+
28+        if (self.command) |cmd_args| {
29+            const argv = try alloc.allocSentinel(?[*:0]const u8, cmd_args.len, null);
30+            for (cmd_args, 0..) |arg, i| {
31+                argv[i] = try alloc.dupeZ(u8, arg);
32+            }
33+            const err = std.posix.execvpeZ(argv[0].?, argv.ptr, std.c.environ);
34+            std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
35+            std.posix.exit(1);
36+        }
37+
38+        const shell = util.detectShell();
39+        // Use "-shellname" as argv[0] to signal login shell (traditional method)
40+        const login_shell = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{std.fs.path.basename(shell)}, 0);
41+        const argv = [_:null]?[*:0]const u8{ login_shell, null };
42+        const err = std.posix.execveZ(shell, &argv, std.c.environ);
43+        std.log.err("execve failed: err={s}", .{@errorName(err)});
44+        std.posix.exit(1);
45+    }
46+
47     fn spawnPty(self: *Daemon) !c_int {
48         const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
49         var ws: cross.c.struct_winsize = .{
50@@ -310,32 +352,15 @@ const Daemon = struct {
51         }
52 
53         if (pid == 0) { // child pid code path
54-            const session_env = try std.fmt.allocPrint(self.alloc, "ZMX_SESSION={s}\x00", .{self.session_name});
55-            _ = cross.c.putenv(@ptrCast(session_env.ptr));
56-
57-            if (self.command) |cmd_args| {
58-                const alloc = std.heap.c_allocator;
59-                var argv_buf: [64:null]?[*:0]const u8 = undefined;
60-                for (cmd_args, 0..) |arg, i| {
61-                    argv_buf[i] = alloc.dupeZ(u8, arg) catch {
62-                        std.posix.exit(1);
63-                    };
64-                }
65-                argv_buf[cmd_args.len] = null;
66-                const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
67-                const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
68-                std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
69-                std.posix.exit(1);
70-            } else {
71-                const shell = util.detectShell();
72-                // Use "-shellname" as argv[0] to signal login shell (traditional method)
73-                var buf: [64]u8 = undefined;
74-                const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
75-                const argv = [_:null]?[*:0]const u8{ login_shell, null };
76-                const err = std.posix.execveZ(shell, &argv, std.c.environ);
77-                std.log.err("execve failed: err={s}", .{@errorName(err)});
78+            // In the forked child, ANY error must exit rather than propagate:
79+            // a returned error falls through to the parent code path below,
80+            // running a second daemon on the same socket (or worse, hitting
81+            // errdefers that delete the parent's socket file).
82+            execChild(self) catch |err| {
83+                std.log.err("child setup failed: {s}", .{@errorName(err)});
84                 std.posix.exit(1);
85-            }
86+            };
87+            unreachable; // execChild either execs or exits, never returns ok
88         }
89         // master pid code path
90         self.pid = pid;