Commit 758a137
Eric Bower
·
2026-04-24 16:08:27 -0400 EDT
parent 5ebf61b
refactor(run): check pty foreground process to detect shell This removes the need to provide the `--fish` flag at all for `zmx run` commands.
4 files changed,
+76,
-33
+59,
-0
| ... | ... | @@ -1,8 +1,11 @@ | |
| 1 | 1 | const builtin = @import("builtin"); | |
| 2 | + | const std = @import("std"); | |
| 3 | + | const posix = std.posix; | |
| 2 | 4 | ||
| 3 | 5 | pub const c = switch (builtin.os.tag) { | |
| 4 | 6 | .macos => @cImport({ | |
| 5 | 7 | @cInclude("sys/ioctl.h"); // ioctl and constants | |
| 8 | + | @cInclude("sys/sysctl.h"); // sysctl for process name lookup | |
| 6 | 9 | @cInclude("termios.h"); | |
| 7 | 10 | @cInclude("stdlib.h"); | |
| 8 | 11 | @cInclude("unistd.h"); |
| ... | ... | @@ -28,3 +31,59 @@ pub const forkpty = if (builtin.os.tag == .macos) | |
| 28 | 31 | }.forkpty | |
| 29 | 32 | else | |
| 30 | 33 | c.forkpty; | |
| 34 | + | ||
| 35 | + | /// Returns the basename of the foreground process running on the given PTY fd. | |
| 36 | + | /// Writes into `buf` and returns a slice of it, or null on failure. | |
| 37 | + | pub fn getForegroundProcessName(pty_fd: i32, buf: []u8) ?[]const u8 { | |
| 38 | + | const pgid = c.tcgetpgrp(pty_fd); | |
| 39 | + | if (pgid <= 0) return null; | |
| 40 | + | ||
| 41 | + | switch (builtin.os.tag) { | |
| 42 | + | .macos => { | |
| 43 | + | // Use KERN_PROC_PGRP to find the process in the foreground group. | |
| 44 | + | // We walk the process list and find the first process whose pgid matches. | |
| 45 | + | var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PGRP, @intCast(pgid) }; | |
| 46 | + | var size: usize = 0; | |
| 47 | + | if (c.sysctl(&mib, mib.len, null, &size, null, 0) != 0) return null; | |
| 48 | + | if (size == 0) return null; | |
| 49 | + | ||
| 50 | + | // kinfo_proc is large; allocate on heap to avoid blowing the stack | |
| 51 | + | const kinfo_size = @sizeOf(c.struct_kinfo_proc); | |
| 52 | + | const count = size / kinfo_size; | |
| 53 | + | if (count == 0) return null; | |
| 54 | + | ||
| 55 | + | // Use a stack buffer for small lists (usually 1-3 procs), heap otherwise. | |
| 56 | + | var stack_buf: [8 * @sizeOf(c.struct_kinfo_proc)]u8 align(@alignOf(c.struct_kinfo_proc)) = undefined; | |
| 57 | + | const heap_needed = size > stack_buf.len; | |
| 58 | + | const proc_buf: []u8 = if (heap_needed) | |
| 59 | + | std.heap.c_allocator.alloc(u8, size) catch return null | |
| 60 | + | else | |
| 61 | + | stack_buf[0..size]; | |
| 62 | + | defer if (heap_needed) std.heap.c_allocator.free(proc_buf); | |
| 63 | + | ||
| 64 | + | if (c.sysctl(&mib, mib.len, proc_buf.ptr, &size, null, 0) != 0) return null; | |
| 65 | + | ||
| 66 | + | const procs: []c.struct_kinfo_proc = @alignCast(std.mem.bytesAsSlice(c.struct_kinfo_proc, proc_buf[0..size])); | |
| 67 | + | if (procs.len == 0) return null; | |
| 68 | + | ||
| 69 | + | // p_comm is a null-terminated fixed-length field | |
| 70 | + | const comm: [*:0]const u8 = @ptrCast(&procs[0].kp_proc.p_comm); | |
| 71 | + | const name = std.mem.sliceTo(comm, 0); | |
| 72 | + | const copy_len = @min(name.len, buf.len); | |
| 73 | + | @memcpy(buf[0..copy_len], name[0..copy_len]); | |
| 74 | + | return buf[0..copy_len]; | |
| 75 | + | }, | |
| 76 | + | .linux => { | |
| 77 | + | // /proc/<pid>/comm contains just the process name + newline | |
| 78 | + | var path_buf: [64]u8 = undefined; | |
| 79 | + | const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/comm", .{pgid}) catch return null; | |
| 80 | + | const file = std.fs.openFileAbsolute(path, .{}) catch return null; | |
| 81 | + | defer file.close(); | |
| 82 | + | const n = file.read(buf) catch return null; | |
| 83 | + | // strip trailing newline | |
| 84 | + | const end = if (n > 0 and buf[n - 1] == '\n') n - 1 else n; | |
| 85 | + | return buf[0..end]; | |
| 86 | + | }, | |
| 87 | + | else => return null, | |
| 88 | + | } | |
| 89 | + | } |
+15,
-25
| ... | ... | @@ -154,15 +154,8 @@ pub fn main() !void { | |
| 154 | 154 | ||
| 155 | 155 | var cmd_args_raw: std.ArrayList([]const u8) = .empty; | |
| 156 | 156 | defer cmd_args_raw.deinit(alloc); | |
| 157 | - | var shell_basename: []const u8 = "bash"; | |
| 158 | 157 | var detached = false; | |
| 159 | 158 | while (args.next()) |arg| { | |
| 160 | - | // Because fish tracks exit code status via $status instead of $? we need some | |
| 161 | - | // way to figure out what shell is being used inside the session. | |
| 162 | - | if (std.mem.startsWith(u8, arg, "--fish")) { | |
| 163 | - | shell_basename = "fish"; | |
| 164 | - | continue; | |
| 165 | - | } | |
| 166 | 159 | if (std.mem.startsWith(u8, arg, "-d")) { | |
| 167 | 160 | detached = true; | |
| 168 | 161 | continue; |
| ... | ... | @@ -195,7 +188,7 @@ pub fn main() !void { | |
| 195 | 188 | error.OutOfMemory => return err, | |
| 196 | 189 | }; | |
| 197 | 190 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 198 | - | return run(&daemon, detached, shell_basename, cmd_args_raw.items); | |
| 191 | + | return run(&daemon, detached, cmd_args_raw.items); | |
| 199 | 192 | } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) { | |
| 200 | 193 | const session_name = args.next() orelse ""; | |
| 201 | 194 | if (session_name.len == 0) return error.SessionNameRequired; |
| ... | ... | @@ -551,6 +544,7 @@ const Daemon = struct { | |
| 551 | 544 | task_exit_code: ?u8 = null, // null = running or n/a, set when task completes | |
| 552 | 545 | task_ended_at: ?u64 = null, // timestamp when task exited | |
| 553 | 546 | is_fish: bool = false, // true if session shell is fish (affects exit code variable) | |
| 547 | + | pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process | |
| 554 | 548 | pty_write_buf: std.ArrayList(u8) = .empty, | |
| 555 | 549 | ||
| 556 | 550 | const EnsureSessionResult = struct { |
| ... | ... | @@ -1101,9 +1095,15 @@ const Daemon = struct { | |
| 1101 | 1095 | ||
| 1102 | 1096 | if (payload.len == 0) return; | |
| 1103 | 1097 | ||
| 1104 | - | // First byte indicates shell type (0=bash/zsh, 1=fish) | |
| 1105 | - | self.is_fish = payload[0] == 1; | |
| 1106 | - | const cmd = payload[1..]; | |
| 1098 | + | // Auto-detect the foreground process on the PTY to determine shell type. | |
| 1099 | + | if (self.pty_fd >= 0) { | |
| 1100 | + | var name_buf: [64]u8 = undefined; | |
| 1101 | + | if (cross.getForegroundProcessName(self.pty_fd, &name_buf)) |name| { | |
| 1102 | + | self.is_fish = std.mem.eql(u8, name, "fish"); | |
| 1103 | + | std.log.debug("foreground process={s} is_fish={}", .{ name, self.is_fish }); | |
| 1104 | + | } | |
| 1105 | + | } | |
| 1106 | + | const cmd = payload; | |
| 1107 | 1107 | ||
| 1108 | 1108 | // Daemon appends the task marker so the client never injects | |
| 1109 | 1109 | // shell-specific syntax, keeping Ctrl-C recovery clean. |
| ... | ... | @@ -1221,7 +1221,7 @@ fn help() !void { | |
| 1221 | 1221 | \\ | |
| 1222 | 1222 | \\Commands: | |
| 1223 | 1223 | \\ [a]ttach <name> [command...] Attach to session, creating if needed | |
| 1224 | - | \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 1224 | + | \\ [r]un <name> [-d] [command...] Send command without attaching | |
| 1225 | 1225 | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 1226 | 1226 | \\ [p]rint <name> <text...> Inject text into session display | |
| 1227 | 1227 | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session |
| ... | ... | @@ -1255,8 +1255,6 @@ fn help() !void { | |
| 1255 | 1255 | \\ Commands run sequentially: do not send multiple in parallel. | |
| 1256 | 1256 | \\ Avoid interactive programs (pagers, editors, prompts): they hang. | |
| 1257 | 1257 | \\ | |
| 1258 | - | \\ `--fish` is required when the session runs fish shell. | |
| 1259 | - | \\ | |
| 1260 | 1258 | \\ If the command hangs, send Ctrl+C to recover: | |
| 1261 | 1259 | \\ zmx run <session> $(printf '\x03') | |
| 1262 | 1260 | \\ |
| ... | ... | @@ -1268,7 +1266,6 @@ fn help() !void { | |
| 1268 | 1266 | \\ | |
| 1269 | 1267 | \\ Examples: | |
| 1270 | 1268 | \\ zmx run dev ls | |
| 1271 | - | \\ zmx run dev --fish ls src | |
| 1272 | 1269 | \\ zmx run dev zig build | |
| 1273 | 1270 | \\ zmx run dev grep -r TODO src | |
| 1274 | 1271 | \\ zmx run dev git -c core.pager=cat diff |
| ... | ... | @@ -2029,7 +2026,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts | |
| 2029 | 2026 | }; | |
| 2030 | 2027 | } | |
| 2031 | 2028 | ||
| 2032 | - | fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void { | |
| 2029 | + | fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void { | |
| 2033 | 2030 | const alloc = daemon.alloc; | |
| 2034 | 2031 | var buf: [4096]u8 = undefined; | |
| 2035 | 2032 | var w = std.fs.File.stdout().writer(&buf); |
| ... | ... | @@ -2046,17 +2043,10 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args | |
| 2046 | 2043 | try w.interface.flush(); | |
| 2047 | 2044 | } | |
| 2048 | 2045 | ||
| 2049 | - | // Prefix byte tells the daemon which shell syntax to use for the | |
| 2050 | - | // task-completion marker (0 = bash/zsh $?, 1 = fish $status). | |
| 2051 | - | // The daemon appends the marker itself so the client never injects | |
| 2052 | - | // shell-specific text -- keeping recovery (Ctrl-C) clean. | |
| 2053 | - | const is_fish: u8 = if (std.mem.eql(u8, shell_basename, "fish")) 1 else 0; | |
| 2054 | - | ||
| 2055 | 2046 | if (command_args.len > 0) { | |
| 2056 | 2047 | var cmd_list = std.ArrayList(u8).empty; | |
| 2057 | 2048 | defer cmd_list.deinit(alloc); | |
| 2058 | 2049 | ||
| 2059 | - | try cmd_list.append(alloc, is_fish); | |
| 2060 | 2050 | for (command_args, 0..) |arg, i| { | |
| 2061 | 2051 | if (i > 0) try cmd_list.append(alloc, ' '); | |
| 2062 | 2052 | if (util.shellNeedsQuoting(arg)) { |
| ... | ... | @@ -2082,7 +2072,6 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args | |
| 2082 | 2072 | var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096); | |
| 2083 | 2073 | defer stdin_buf.deinit(alloc); | |
| 2084 | 2074 | ||
| 2085 | - | try stdin_buf.append(alloc, is_fish); | |
| 2086 | 2075 | while (true) { | |
| 2087 | 2076 | var tmp: [4096]u8 = undefined; | |
| 2088 | 2077 | const n = posix.read(stdin_fd, &tmp) catch |err| { |
| ... | ... | @@ -2093,7 +2082,7 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args | |
| 2093 | 2082 | try stdin_buf.appendSlice(alloc, tmp[0..n]); | |
| 2094 | 2083 | } | |
| 2095 | 2084 | ||
| 2096 | - | if (stdin_buf.items.len > 1) { | |
| 2085 | + | if (stdin_buf.items.len > 0) { | |
| 2097 | 2086 | // Normalize any trailing newline to CR so readline (raw mode) | |
| 2098 | 2087 | // accepts each line. | |
| 2099 | 2088 | if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') { |
| ... | ... | @@ -2316,6 +2305,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2316 | 2305 | /// clients. It uses poll() as its non-blocking mechanism. | |
| 2317 | 2306 | fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 2318 | 2307 | std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd }); | |
| 2308 | + | daemon.pty_fd = pty_fd; | |
| 2319 | 2309 | setupSigtermHandler(); | |
| 2320 | 2310 | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8); | |
| 2321 | 2311 | defer poll_fds.deinit(daemon.alloc); |
+2,
-2
| ... | ... | @@ -208,7 +208,7 @@ load test_helper | |
| 208 | 208 | # ============================================================================ | |
| 209 | 209 | ||
| 210 | 210 | @test "wait: returns after session command completes" { | |
| 211 | - | "$ZMX" run test-wait -d $SHELL_FLAG echo done | |
| 211 | + | "$ZMX" run test-wait -d echo done | |
| 212 | 212 | wait_for_session test-wait | |
| 213 | 213 | sleep 1 # give the command time to finish | |
| 214 | 214 |
| ... | ... | @@ -239,7 +239,7 @@ load test_helper | |
| 239 | 239 | # ============================================================================ | |
| 240 | 240 | ||
| 241 | 241 | @test "print: text appears in history" { | |
| 242 | - | "$ZMX" run test-print-hist -d $SHELL_FLAG echo ready | |
| 242 | + | "$ZMX" run test-print-hist -d echo ready | |
| 243 | 243 | wait_for_session test-print-hist | |
| 244 | 244 | sleep 0.3 | |
| 245 | 245 |
+0,
-6
| ... | ... | @@ -9,12 +9,6 @@ setup() { | |
| 9 | 9 | fi | |
| 10 | 10 | ZMX="$REPO_DIR/zig-out/bin/zmx" | |
| 11 | 11 | ||
| 12 | - | # Detect shell so task-completion markers use the right syntax | |
| 13 | - | case "$(basename "$SHELL")" in | |
| 14 | - | fish) SHELL_FLAG="--fish" ;; | |
| 15 | - | *) SHELL_FLAG="" ;; | |
| 16 | - | esac | |
| 17 | - | ||
| 18 | 12 | # Isolate socket dir so tests don't interfere with real sessions or each other | |
| 19 | 13 | export ZMX_DIR="$BATS_TEST_TMPDIR/zmx-sockets" | |
| 20 | 14 | mkdir -p "$ZMX_DIR" |