repos / zmx

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

commit
690487b
parent
12a19ee
author
Ian Tay
date
2026-03-08 12:54:40 -0400 EDT
fix: signal handling — ignore SIGPIPE, handle EINTR in poll

- Ignore SIGPIPE once in main() (inherited across fork, covers all
  subcommands). Without this, writing to a closed socket delivers
  SIGPIPE (default disposition: terminate) before write() can return
  EPIPE. An abrupt client exit killed the daemon; a daemon that died
  between probe and send killed one-shot clients (run/kill/history).
- Handle EINTR in the daemon's poll loop (clientLoop already does this).
  Without this, SIGTERM/SIGCHLD caused the daemon to exit with an error
  instead of checking the sigterm flag and shutting down gracefully.

Note: SA_RESTART is intentionally NOT set on SIGTERM/SIGWINCH. On BSD/macOS
(unlike Linux), poll() is restartable when SA_RESTART is set — an idle
daemon would never wake from poll() to check the sigterm flag. The EINTR
handling in the poll loop is the correct and sufficient fix.
1 files changed,  +23, -1
M src/main.zig
+23, -1
 1@@ -40,6 +40,12 @@ pub fn main() !void {
 2     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 3     const alloc = std.heap.c_allocator;
 4 
 5+    // Every subcommand may write to a Unix-domain socket; a peer that
 6+    // disappears between probe and send would otherwise kill us before
 7+    // write() can return BrokenPipe. Inherited across fork, so this also
 8+    // covers the daemon.
 9+    ignoreSigpipe();
10+
11     var args = try std.process.argsWithAllocator(alloc);
12     defer args.deinit();
13     _ = args.skip(); // skip program name
14@@ -1068,7 +1074,10 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
15     };
16     defer posix.close(probe_result.fd);
17 
18-    try ipc.send(probe_result.fd, .Run, cmd_to_send.?);
19+    ipc.send(probe_result.fd, .Run, cmd_to_send.?) catch |err| switch (err) {
20+        error.ConnectionResetByPeer, error.BrokenPipe => return,
21+        else => return err,
22+    };
23 
24     var poll_fds = [_]posix.pollfd{.{ .fd = probe_result.fd, .events = posix.POLL.IN, .revents = 0 }};
25     const poll_result = posix.poll(&poll_fds, 5000) catch return error.PollFailed;
26@@ -1301,6 +1310,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
27         }
28 
29         _ = posix.poll(poll_fds.items, -1) catch |err| {
30+            if (err == error.Interrupted) continue;
31             return err;
32         };
33 
34@@ -1461,6 +1471,9 @@ fn handleSigterm(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c)
35     sigterm_received.store(true, .release);
36 }
37 
38+// No SA_RESTART on these: we WANT the signal to interrupt poll() so the
39+// loop can check the flag. On BSD/macOS, SA_RESTART makes poll restartable,
40+// which would leave an idle daemon deaf to SIGTERM until other I/O wakes it.
41 fn setupSigwinchHandler() void {
42     const act: posix.Sigaction = .{
43         .handler = .{ .sigaction = handleSigwinch },
44@@ -1478,3 +1491,12 @@ fn setupSigtermHandler() void {
45     };
46     posix.sigaction(posix.SIG.TERM, &act, null);
47 }
48+
49+fn ignoreSigpipe() void {
50+    const act: posix.Sigaction = .{
51+        .handler = .{ .handler = posix.SIG.IGN },
52+        .mask = posix.sigemptyset(),
53+        .flags = 0,
54+    };
55+    posix.sigaction(posix.SIG.PIPE, &act, null);
56+}