318 lines
12 KiB
Zig
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;
|
|
}
|