//! A pseudoterminal pair with a child process (the shell) running on the //! slave side. libghostty-vt deliberately has no opinion about process //! management, so this is ours to own. //! //! The libc calls are declared directly rather than going through std.posix. //! PTY setup is inherently POSIX-specific, and std.posix has been churning, //! so explicit externs keep this module readable and stable. const std = @import("std"); const Pty = @This(); // Linux ioctl request numbers. This app is Linux-only (GTK4/Wayland), so // inlining these is simpler than chasing them through std across versions. const TIOCSCTTY = 0x540E; const TIOCSWINSZ = 0x5414; const fd_t = std.c.fd_t; const pid_t = std.c.pid_t; /// The libc surface this module needs, kept in its own namespace so the /// names don't collide with our own methods. const c = struct { extern "c" fn posix_openpt(flags: c_int) c_int; extern "c" fn grantpt(fd: c_int) c_int; extern "c" fn unlockpt(fd: c_int) c_int; extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int; extern "c" fn setsid() pid_t; extern "c" fn fork() pid_t; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize; extern "c" fn close(fd: fd_t) c_int; extern "c" fn dup2(old: fd_t, new: fd_t) c_int; extern "c" fn open(path: [*:0]const u8, flags: c_int, ...) c_int; extern "c" fn ioctl(fd: fd_t, request: c_ulong, ...) c_int; extern "c" fn write(fd: fd_t, buf: [*]const u8, count: usize) isize; extern "c" fn read(fd: fd_t, buf: [*]u8, count: usize) isize; extern "c" fn execvpe( file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8, ) c_int; extern "c" fn _exit(code: c_int) noreturn; extern "c" fn kill(pid: pid_t, sig: c_int) c_int; extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t; extern "c" fn getuid() std.c.uid_t; extern "c" fn getpwuid_r( uid: std.c.uid_t, pwd: *std.c.passwd, buf: [*]u8, buflen: usize, result: *?*std.c.passwd, ) c_int; }; const O_RDWR = 0x0002; const O_NOCTTY = 0x0100; const SIGHUP = 1; pub const Winsize = extern struct { ws_row: u16, ws_col: u16, ws_xpixel: u16 = 0, ws_ypixel: u16 = 0, }; /// The master side of the pair. Read terminal output from it, write user /// input to it. master: fd_t, /// PID of the child process on the slave side. pid: pid_t, pub const Error = error{ OpenPtFailed, GrantPtFailed, UnlockPtFailed, PtsNameFailed, ForkFailed, }; /// Open a PTY pair and fork `path` onto the slave side. The child gets its /// own session with the slave as controlling terminal, so job control and /// signals (Ctrl-C, SIGWINCH) work the way a shell expects. /// /// `path` is the executable to run and `argv` is the argument vector it /// sees, including argv[0]. These are separate because a login shell is /// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding /// them together would pass "-zsh" as an ordinary argument instead, which /// shells either misparse or reject outright. /// /// `dir` is the directory the child starts in. A null, or a directory that /// cannot be entered, leaves it wherever the app was started — a layout /// naming a path that no longer exists should still give you a usable shell. /// /// `env_extra` is added to the child's environment as `NAME=value` strings, /// replacing any the parent already had under the same name. This is how a /// shell learns about the tab it is running in. pub fn create( alloc: std.mem.Allocator, path: [:0]const u8, argv: []const [:0]const u8, dir: ?[:0]const u8, env_extra: []const []const u8, size: Winsize, ) !Pty { const master = c.posix_openpt(O_RDWR | O_NOCTTY); if (master < 0) return Error.OpenPtFailed; errdefer _ = c.close(master); if (c.grantpt(master) != 0) return Error.GrantPtFailed; if (c.unlockpt(master) != 0) return Error.UnlockPtFailed; var name_buf: [128]u8 = undefined; if (c.ptsname_r(master, &name_buf, name_buf.len) != 0) return Error.PtsNameFailed; const slave_path = std.mem.sliceTo(&name_buf, 0); _ = c.ioctl(master, TIOCSWINSZ, &size); // Everything the child needs must be allocated before the fork. After // forking, only async-signal-safe work is legal in the child. const argv_z = try alloc.allocSentinel(?[*:0]const u8, argv.len, null); defer alloc.free(argv_z); for (argv, 0..) |arg, i| argv_z[i] = arg.ptr; const envp_z = try buildEnv(alloc, env_extra); defer freeEnv(alloc, envp_z); const slave_path_z = try alloc.dupeSentinel(u8, slave_path, 0); defer alloc.free(slave_path_z); const pid = c.fork(); if (pid < 0) return Error.ForkFailed; if (pid == 0) { childExec(master, slave_path_z, path, argv_z, envp_z, dir); // childExec only returns on failure, and a forked child has no // sensible way to report that back to us. c._exit(127); } return .{ .master = master, .pid = pid }; } /// The child half of the fork. Only returns if something failed. fn childExec( master: fd_t, slave_path: [:0]const u8, path: [:0]const u8, argv: [:null]const ?[*:0]const u8, envp: [:null]const ?[*:0]const u8, dir: ?[:0]const u8, ) void { _ = c.close(master); // Deliberately unchecked: a layout pointing at a directory that has since // been moved or deleted should still open a shell, in the directory the // app was started from, rather than a pane that dies on arrival. if (dir) |d| if (d.len > 0) { _ = c.chdir(d.ptr); }; // A new session detaches us from the parent's controlling terminal so // that we can claim the slave as our own below. if (c.setsid() < 0) return; const slave = c.open(slave_path.ptr, O_RDWR); if (slave < 0) return; // Claim the slave as this session's controlling terminal. Without this // the shell has no way to deliver SIGINT to foreground jobs. if (c.ioctl(slave, TIOCSCTTY, @as(c_int, 0)) < 0) return; if (c.dup2(slave, 0) < 0) return; if (c.dup2(slave, 1) < 0) return; if (c.dup2(slave, 2) < 0) return; if (slave > 2) _ = c.close(slave); _ = c.execvpe(path.ptr, argv.ptr, envp.ptr); } /// The login shell recorded for the current user in the passwd database, /// copied into `buf`. Returns null if there is no entry or it names no shell. /// /// This is the shell the user actually configured (what `chsh` sets). Unlike /// $SHELL it cannot be overwritten by whatever environment the terminal was /// launched from. pub fn loginShell(buf: []u8) ?[]const u8 { // getpwuid_r writes the strings it returns into a caller-provided scratch // buffer, kept separate from `buf` so the result survives the copy out. var scratch: [4096]u8 = undefined; var pw: std.c.passwd = undefined; var result: ?*std.c.passwd = null; if (c.getpwuid_r(c.getuid(), &pw, &scratch, scratch.len, &result) != 0) return null; const entry = result orelse return null; const shell = entry.shell orelse return null; const span = std.mem.span(shell); if (span.len == 0 or span.len > buf.len) return null; @memcpy(buf[0..span.len], span); return buf[0..span.len]; } /// Copy the current environment, forcing the variables that describe what /// kind of terminal we are, and adding whatever the caller supplied. We /// advertise xterm-256color rather than ghostty's own terminfo because we /// don't install a terminfo entry. /// /// Anything we are about to define is dropped from the inherited copy first, /// so a variable set in playpen's own environment cannot shadow the value this /// pane is supposed to see — which matters most for the ones that describe the /// pane itself, since inheriting a stale one is worse than having none. fn buildEnv( alloc: std.mem.Allocator, extra: []const []const u8, ) ![:null]?[*:0]const u8 { var list: std.ArrayListUnmanaged([*:0]const u8) = .empty; defer list.deinit(alloc); errdefer for (list.items) |item| alloc.free(std.mem.span(item)); var i: usize = 0; while (std.c.environ[i]) |entry| : (i += 1) { const span = std.mem.span(entry); // Drop the variables we're about to define ourselves. if (std.mem.startsWith(u8, span, "TERM=")) continue; if (std.mem.startsWith(u8, span, "COLORTERM=")) continue; if (shadowedBy(span, extra)) continue; try list.append(alloc, (try alloc.dupeSentinel(u8, span, 0)).ptr); } try list.append(alloc, (try alloc.dupeSentinel(u8, "TERM=xterm-256color", 0)).ptr); try list.append(alloc, (try alloc.dupeSentinel(u8, "COLORTERM=truecolor", 0)).ptr); for (extra) |entry| { try list.append(alloc, (try alloc.dupeSentinel(u8, entry, 0)).ptr); } const result = try alloc.allocSentinel(?[*:0]const u8, list.items.len, null); for (list.items, 0..) |item, idx| result[idx] = item; return result; } /// Whether an inherited `NAME=value` entry names a variable the caller is /// about to define. pub fn shadowedBy(entry: []const u8, extra: []const []const u8) bool { const eq = std.mem.indexOfScalar(u8, entry, '=') orelse return false; for (extra) |candidate| { const candidate_eq = std.mem.indexOfScalar(u8, candidate, '=') orelse continue; if (std.mem.eql(u8, entry[0 .. eq + 1], candidate[0 .. candidate_eq + 1])) return true; } return false; } fn freeEnv(alloc: std.mem.Allocator, envp: [:null]?[*:0]const u8) void { for (envp) |entry| if (entry) |e| alloc.free(std.mem.span(e)); alloc.free(envp); } /// The child's current working directory, copied into `buf`, or null if it /// can't be read. /// /// This is the shell's directory as of right now, not the one it started in, /// which is what makes "save this tab as a layout" record where you actually /// are rather than where you began. Read straight out of procfs: there is no /// portable API for it, and this app is Linux-only regardless. pub fn cwd(self: Pty, buf: []u8) ?[]const u8 { return cwdOf(self.pid, buf); } /// `cwd` for any process, not only one this module started — a herdr-core /// terminal's program, which runs on this machine but is nobody's child here. pub fn cwdOf(pid: pid_t, buf: []u8) ?[]const u8 { var link_buf: [64]u8 = undefined; const link = std.mem.printSentinel(&link_buf, "/proc/{d}/cwd", .{pid}, 0) catch return null; const n = c.readlink(link.ptr, buf.ptr, buf.len); if (n <= 0) return null; const len: usize = @intCast(n); // readlink truncates rather than failing, and a truncated path is worse // than none: it would silently point somewhere else. if (len >= buf.len) return null; return buf[0..len]; } /// Tell the child its window changed size. This both updates the kernel's /// idea of the terminal size and delivers SIGWINCH to the foreground group. pub fn setSize(self: Pty, size: Winsize) void { _ = c.ioctl(self.master, TIOCSWINSZ, &size); } /// Returns the number of bytes read, or null if the PTY hung up. pub fn read(self: Pty, buf: []u8) ?usize { const n = c.read(self.master, buf.ptr, buf.len); if (n <= 0) return null; return @intCast(n); } pub fn writeAll(self: Pty, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = c.write(self.master, bytes.ptr + off, bytes.len - off); // Best-effort: if the child has exited the write fails with EPIPE // and there is nothing useful to do about it here. if (n <= 0) return; off += @intCast(n); } } pub fn deinit(self: *Pty) void { _ = c.close(self.master); // Closing the master sends SIGHUP to the child's session. Reap it so we // don't leave a zombie behind. _ = c.kill(self.pid, SIGHUP); _ = c.waitpid(self.pid, null, 0); self.* = undefined; }