diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/pty.zig | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/pty.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/pty.zig | 519 |
1 files changed, 519 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/pty.zig b/vendor/libghostty-vt/src/pty.zig new file mode 100644 index 0000000..de42ef5 --- /dev/null +++ b/vendor/libghostty-vt/src/pty.zig @@ -0,0 +1,519 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const windows = @import("os/main.zig").windows; +const posix = std.posix; +const assert = @import("quirks.zig").inlineAssert; + +const log = std.log.scoped(.pty); + +/// Redeclare this winsize struct so we can just use a Zig struct. This +/// layout should be correct on all tested platforms. The defaults on this +/// are some reasonable screen size but you should probably not use them. +pub const winsize = extern struct { + ws_row: u16 = 100, + ws_col: u16 = 80, + ws_xpixel: u16 = 800, + ws_ypixel: u16 = 600, +}; + +pub const Pty = switch (builtin.os.tag) { + .windows => WindowsPty, + .ios => NullPty, + else => PosixPty, +}; + +/// The modes of a pty. Not all of these modes are supported on +/// all platforms but all platforms share the same mode struct. +/// +/// The default values of fields in this struct are set to the +/// most typical values for a pty. This makes it easier for cross-platform +/// code which doesn't support all of the modes to work correctly. +pub const Mode = packed struct { + /// ICANON on POSIX + canonical: bool = true, + + /// ECHO on POSIX + echo: bool = true, +}; + +pub const ProcessInfo = enum { + /// The PID of the process that controls the PTY. + foreground_pid, + /// Gets the name of the slave PTY. Returned name points to an internal buffer + /// so it should not be modified or freed. + tty_name, + + pub fn Type(comptime info: ProcessInfo) type { + return switch (info) { + .foreground_pid => u64, + .tty_name => [:0]const u8, + }; + } +}; + +// A pty implementation that does nothing. +// +// TODO: This should be removed. This is only temporary until we have +// a termio that doesn't use a pty. This isn't used in any user-facing +// artifacts, this is just a stopgap to get compilation to work on iOS. +const NullPty = struct { + pub const Error = OpenError || GetModeError || SetSizeError || ChildPreExecError; + + pub const Fd = posix.fd_t; + + master: Fd, + slave: Fd, + + pub const OpenError = error{}; + + pub fn open(size: winsize) OpenError!Pty { + _ = size; + return .{ .master = 0, .slave = 0 }; + } + + pub fn deinit(self: *Pty) void { + _ = self; + } + + pub const GetModeError = error{GetModeFailed}; + + pub fn getMode(self: Pty) GetModeError!Mode { + _ = self; + return .{}; + } + + pub const SetSizeError = error{}; + + pub fn setSize(self: *Pty, size: winsize) SetSizeError!void { + _ = self; + _ = size; + } + + pub const ChildPreExecError = error{}; + + pub fn childPreExec(self: Pty) ChildPreExecError!void { + _ = self; + } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(_: *Pty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return null; + } +}; + +/// Posix PTY creation and management. This is just a thin layer on top +/// of Posix syscalls. The caller is responsible for detail-oriented handling +/// of the returned file handles. +const PosixPty = struct { + pub const Error = OpenError || GetModeError || GetSizeError || SetSizeError || ChildPreExecError; + + pub const Fd = posix.fd_t; + + const c = @import("pty-c"); + + /// The file descriptors for the master and slave side of the pty. + /// The slave side is never closed automatically by this struct + /// so the caller is responsible for closing it if things + /// go wrong. + master: Fd, + slave: Fd, + + /// Buffer for storage of slave tty name so that we don't have to recompute + /// it every time we need it. + tty_name_buf: [std.fs.max_path_bytes:0]u8 = undefined, + /// The name of slave tty. If `null` it has not yet been computed or + /// may not be available. Should not be accessed directly, but through + /// `self.getProcessInfo(.tty_name)` + tty_name: ?[:0]const u8 = null, + + pub const OpenError = error{OpenptyFailed}; + + /// Open a new PTY with the given initial size. + pub fn open(size: winsize) OpenError!Pty { + // Need to copy so that it becomes non-const. + var sizeCopy = size; + + var master_fd: Fd = undefined; + var slave_fd: Fd = undefined; + if (c.openpty( + &master_fd, + &slave_fd, + null, + null, + @ptrCast(&sizeCopy), + ) < 0) + return error.OpenptyFailed; + errdefer { + _ = posix.system.close(master_fd); + _ = posix.system.close(slave_fd); + } + + // Set CLOEXEC on the master fd, only the slave fd should be inherited + // by the child process (shell/command). + cloexec: { + const flags = posix.system.fcntl(master_fd, posix.F.GETFD); + if (flags == -1) { + log.warn("error getting flags for master fd err=E{s}", .{@tagName(posix.errno(-1))}); + break :cloexec; + } + + switch (posix.errno(posix.system.fcntl( + master_fd, + posix.F.SETFD, + flags | posix.FD_CLOEXEC, + ))) { + .SUCCESS => {}, + else => |e| log.warn("error setting CLOEXEC on master fd err=E{}", .{e}), + } + } + + // Enable UTF-8 mode. I think this is on by default on Linux but it + // is NOT on by default on macOS so we ensure that it is always set. + var attrs: c.termios = undefined; + if (c.tcgetattr(master_fd, &attrs) != 0) + return error.OpenptyFailed; + attrs.c_iflag |= c.IUTF8; + if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0) + return error.OpenptyFailed; + + return .{ + .master = master_fd, + .slave = slave_fd, + .tty_name_buf = undefined, + .tty_name = null, + }; + } + + pub fn deinit(self: *Pty) void { + _ = posix.system.close(self.master); + self.* = undefined; + } + + pub const GetModeError = error{GetModeFailed}; + + pub fn getMode(self: Pty) GetModeError!Mode { + var attrs: c.termios = undefined; + if (c.tcgetattr(self.master, &attrs) != 0) + return error.GetModeFailed; + + return .{ + .canonical = (attrs.c_lflag & c.ICANON) != 0, + .echo = (attrs.c_lflag & c.ECHO) != 0, + }; + } + + pub const GetSizeError = error{IoctlFailed}; + + /// Return the size of the pty. + pub fn getSize(self: Pty) GetSizeError!winsize { + var ws: winsize = undefined; + if (c.ioctl(self.master, c.TIOCGWINSZ, @intFromPtr(&ws)) < 0) + return error.IoctlFailed; + + return ws; + } + + pub const SetSizeError = error{IoctlFailed}; + + /// Set the size of the pty. + pub fn setSize(self: *Pty, size: winsize) SetSizeError!void { + if (c.ioctl(self.master, c.TIOCSWINSZ, @intFromPtr(&size)) < 0) + return error.IoctlFailed; + } + + pub const ChildPreExecError = error{ OperationNotSupported, ProcessGroupFailed, SetControllingTerminalFailed }; + + /// This should be called prior to exec in the forked child process + /// in order to setup the tty properly. + pub fn childPreExec(self: Pty) ChildPreExecError!void { + // Reset our signals + var sa: posix.Sigaction = .{ + .handler = .{ .handler = posix.SIG.DFL }, + .mask = posix.sigemptyset(), + .flags = 0, + }; + posix.sigaction(posix.SIG.ABRT, &sa, null); + posix.sigaction(posix.SIG.ALRM, &sa, null); + posix.sigaction(posix.SIG.BUS, &sa, null); + posix.sigaction(posix.SIG.CHLD, &sa, null); + posix.sigaction(posix.SIG.FPE, &sa, null); + posix.sigaction(posix.SIG.HUP, &sa, null); + posix.sigaction(posix.SIG.ILL, &sa, null); + posix.sigaction(posix.SIG.INT, &sa, null); + posix.sigaction(posix.SIG.PIPE, &sa, null); + posix.sigaction(posix.SIG.SEGV, &sa, null); + posix.sigaction(posix.SIG.TRAP, &sa, null); + posix.sigaction(posix.SIG.TERM, &sa, null); + posix.sigaction(posix.SIG.QUIT, &sa, null); + + // Create a new process group + if (c.setsid() < 0) return error.ProcessGroupFailed; + + // Set controlling terminal + switch (posix.errno(c.ioctl(self.slave, c.TIOCSCTTY, @as(c_ulong, 0)))) { + .SUCCESS => {}, + else => |err| { + log.err("error setting controlling terminal errno={}", .{err}); + return error.SetControllingTerminalFailed; + }, + } + + // Can close master/slave pair now + _ = posix.system.close(self.slave); + _ = posix.system.close(self.master); + } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(self: *PosixPty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return switch (info) { + .foreground_pid => { + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + var pgrp: i32 = undefined; + const rc = linux.tcgetpgrp(self.master, &pgrp); + switch (rc) { + 0 => return @intCast(pgrp), // SUCCESS + else => return null, // Anything else + } + }, + else => { + const rc = c.tcgetpgrp(self.master); + if (rc < 0) return null; + return @intCast(rc); + }, + } + }, + .tty_name => { + if (self.tty_name) |tty_name| return tty_name; + + switch (builtin.os.tag) { + .macos => { + // The macOS TIOCPTYGNAME ioctl does not allow us to + // specify the length of the buffer passed to it, but + // expects it to be at least 128 bytes long. + assert(self.tty_name_buf.len >= 128); + switch (posix.errno(c.ioctl(self.master, c.TIOCPTYGNAME, @intFromPtr(&self.tty_name_buf)))) { + .SUCCESS => { + const tty_name: [:0]const u8 = std.mem.sliceTo(&self.tty_name_buf, 0); + self.tty_name = tty_name; + return tty_name; + }, + else => |err| { + log.err("error getting name of slave PTY errno={t}", .{err}); + return null; + }, + } + }, + .linux => { + if (c.ptsname_r(self.master, &self.tty_name_buf, self.tty_name_buf.len) != 0) return null; + const tty_name: [:0]const u8 = std.mem.sliceTo(&self.tty_name_buf, 0); + self.tty_name = tty_name; + return tty_name; + }, + else => return null, + } + }, + }; + } +}; + +/// Windows PTY creation and management. +const WindowsPty = struct { + pub const Error = OpenError || GetSizeError || SetSizeError; + + pub const Fd = windows.HANDLE; + + // Process-wide counter for pipe names + var pipe_name_counter = std.atomic.Value(u32).init(1); + + out_pipe: windows.HANDLE, + in_pipe: windows.HANDLE, + out_pipe_pty: windows.HANDLE, + in_pipe_pty: windows.HANDLE, + pseudo_console: windows.HPCON, + size: winsize, + + pub const OpenError = error{Unexpected}; + + /// Open a new PTY with the given initial size. + pub fn open(size: winsize) OpenError!Pty { + var pty: Pty = undefined; + + var pipe_path_buf: [128]u8 = undefined; + var pipe_path_buf_w: [128]u16 = undefined; + const pipe_path = std.fmt.bufPrintZ( + &pipe_path_buf, + "\\\\.\\pipe\\LOCAL\\ghostty-pty-{d}-{d}", + .{ + windows.GetCurrentProcessId(), + pipe_name_counter.fetchAdd(1, .monotonic), + }, + ) catch unreachable; + + const pipe_path_w_len = std.unicode.utf8ToUtf16Le( + &pipe_path_buf_w, + pipe_path, + ) catch unreachable; + pipe_path_buf_w[pipe_path_w_len] = 0; + const pipe_path_w = pipe_path_buf_w[0..pipe_path_w_len :0]; + + const security_attributes = windows.SECURITY_ATTRIBUTES{ + .nLength = @sizeOf(windows.SECURITY_ATTRIBUTES), + .bInheritHandle = windows.FALSE, + .lpSecurityDescriptor = null, + }; + + pty.in_pipe = windows.exp.kernel32.CreateNamedPipeW( + pipe_path_w.ptr, + windows.PIPE_ACCESS_OUTBOUND | + windows.FILE_FLAG_FIRST_PIPE_INSTANCE | + windows.FILE_FLAG_OVERLAPPED, + windows.PIPE_TYPE_BYTE, + 1, + 4096, + 4096, + 0, + &security_attributes, + ); + if (pty.in_pipe == windows.INVALID_HANDLE_VALUE) { + return windows.unexpectedError(windows.GetLastError()); + } + errdefer _ = windows.exp.kernel32.CloseHandle(pty.in_pipe); + + var security_attributes_read = security_attributes; + pty.in_pipe_pty = windows.exp.kernel32.CreateFileW( + pipe_path_w.ptr, + windows.GENERIC_READ, + 0, + &security_attributes_read, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + null, + ); + if (pty.in_pipe_pty == windows.INVALID_HANDLE_VALUE) { + return windows.unexpectedError(windows.GetLastError()); + } + errdefer _ = windows.exp.kernel32.CloseHandle(pty.in_pipe_pty); + + // The in_pipe needs to be created as a named pipe, since anonymous + // pipes created with CreatePipe do not support overlapped operations, + // and the IOCP backend of libxev only uses overlapped operations on files. + // + // It would be ideal to use CreatePipe here, so that our pipe isn't + // visible to any other processes. + + // if (windows.exp.kernel32.CreatePipe(&pty.in_pipe_pty, &pty.in_pipe, null, 0) == 0) { + // return windows.unexpectedError(windows.kernel32.GetLastError()); + // } + // errdefer { + // _ = windows.CloseHandle(pty.in_pipe_pty); + // _ = windows.CloseHandle(pty.in_pipe); + // } + + if (windows.exp.kernel32.CreatePipe(&pty.out_pipe, &pty.out_pipe_pty, null, 0) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); + } + errdefer { + _ = windows.exp.kernel32.CloseHandle(pty.out_pipe); + _ = windows.exp.kernel32.CloseHandle(pty.out_pipe_pty); + } + + const SetHandleInformation = struct { + fn f(hObject: windows.HANDLE) !void { + if (windows.exp.kernel32.SetHandleInformation( + hObject, + windows.HANDLE_FLAG_INHERIT, + 0, + ) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); + } + } + }; + + try SetHandleInformation.f(pty.in_pipe); + try SetHandleInformation.f(pty.in_pipe_pty); + try SetHandleInformation.f(pty.out_pipe); + try SetHandleInformation.f(pty.out_pipe_pty); + + const result = windows.exp.kernel32.CreatePseudoConsole( + .{ .X = @intCast(size.ws_col), .Y = @intCast(size.ws_row) }, + pty.in_pipe_pty, + pty.out_pipe_pty, + 0, + &pty.pseudo_console, + ); + if (result != windows.S_OK) return error.Unexpected; + + pty.size = size; + return pty; + } + + pub fn deinit(self: *Pty) void { + _ = windows.exp.kernel32.CloseHandle(self.in_pipe_pty); + _ = windows.exp.kernel32.CloseHandle(self.in_pipe); + _ = windows.exp.kernel32.CloseHandle(self.out_pipe_pty); + _ = windows.exp.kernel32.CloseHandle(self.out_pipe); + _ = windows.exp.kernel32.ClosePseudoConsole(self.pseudo_console); + self.* = undefined; + } + + pub const GetSizeError = error{}; + + /// Return the size of the pty. + pub fn getSize(self: Pty) GetSizeError!winsize { + return self.size; + } + + pub const SetSizeError = error{ResizeFailed}; + + /// Set the size of the pty. + pub fn setSize(self: *Pty, size: winsize) SetSizeError!void { + const result = windows.exp.kernel32.ResizePseudoConsole( + self.pseudo_console, + .{ .X = @intCast(size.ws_col), .Y = @intCast(size.ws_row) }, + ); + + if (result != windows.S_OK) return error.ResizeFailed; + self.size = size; + } + + /// Get information about the process(es) attached to the PTY. Returns + /// `null` if there was an error getting the information or the information + /// is not available on a particular platform. + pub fn getProcessInfo(_: *WindowsPty, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { + return null; + } +}; + +test { + const testing = std.testing; + var ws: winsize = .{ + .ws_row = 50, + .ws_col = 80, + .ws_xpixel = 1, + .ws_ypixel = 1, + }; + + var pty = try Pty.open(ws); + defer pty.deinit(); + + // Initialize size should match what we gave it + try testing.expectEqual(ws, try pty.getSize()); + + // Can set and read new sizes + ws.ws_row *= 2; + try pty.setSize(ws); + try testing.expectEqual(ws, try pty.getSize()); + + switch (builtin.os.tag) { + .freebsd => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), + .linux => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/pts/")), + .macos => try testing.expect(std.mem.startsWith(u8, pty.getProcessInfo(.tty_name).?, "/dev/")), + else => try testing.expect(pty.getProcessInfo(.tty_name) == null), + } +} |
