Files
playpen/src/Pty.zig
T

318 lines
12 KiB
Zig

//! 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;
}