From 0aa71d02ed068a3a036721ab9e480cbdf82ac329 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 10:19:59 -0700 Subject: [PATCH 1/6] terminal: reduce Parser.Action log formatting code size Shrinks libghostty-vt dylib by ~2%. --- src/terminal/Parser.zig | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 88e7edf7c..34e7e30f4 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -166,12 +166,18 @@ pub const Action = union(enum) { // of invisible characters we don't want to handle right // now. - // All others do the default behavior + // All others do the default behavior. Note the max + // depth of 1: this is only used for logging so we + // only want the top-level fields. Larger depths + // instantiate a recursive formatter for every nested + // type of every action payload (e.g. the full + // osc.Command union), which costs tens of kilobytes + // of binary size. else => try writer.printValue( "any", .{}, @field(self, u_field.name), - 3, + 1, ), } } @@ -386,10 +392,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { // We only allow colon or mixed separators for the 'm' command. if (c != 'm' and self.params_sep.count() > 0) { @branchHint(.cold); - log.warn( - "CSI colon or mixed separators only allowed for 'm' command, got: {f}", - .{result}, - ); + warnCsiSepMismatch(result.csi_dispatch); break :csi_dispatch null; } @@ -406,6 +409,20 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action { }; } +/// Log a warning for a CSI dispatch with colon/mixed separators on a +/// non-'m' command. +/// +/// This is noinline on purpose so that this unlikely (cold) behavior +/// doesn't bloat the hot dispatch path which has been measured to actually +/// affect both binary size and performance due to icache busts. +noinline fn warnCsiSepMismatch(csi: Action.CSI) void { + @branchHint(.cold); + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {f}", + .{csi}, + ); +} + pub inline fn clear(self: *Parser) void { self.intermediates_idx = 0; self.params_idx = 0; From f36854345e50fe382f37a6ef5859f9013787a23b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 10:19:59 -0700 Subject: [PATCH 2/6] libghostty: skip stack traces in release panic handlers The default Zig panic handler unwinds the stack and symbolicates it, which drags in ~160KB worth of helper machinery. For an embedded library this isn't great because the embedder's environment should be providing this as long as libghostty is compiled with symbols or has a way to symbolize. Change ReleaseFast/ReleaseSmall libghostty-vt builds to use a custom panic handler. Debug/ReleaseSafe keep the full Zig handlers. This shrinks libghostty-vt on macOS by ~160KB (~9%). --- src/lib_vt.zig | 48 +++++++++++++++++++++++++++++++++++++ src/os/main.zig | 2 ++ src/os/stderr.zig | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/os/stderr.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index c9edd20b0..68f275b26 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -11,6 +11,7 @@ const lib = @This(); const std = @import("std"); const builtin = @import("builtin"); +const stderr = @import("os/stderr.zig"); // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` @@ -414,6 +415,53 @@ pub const std_options: std.Options = opts: { break :opts options; }; +/// The panic handler for when this file is the root module. +/// +/// In ReleaseFast and ReleaseSmall builds we print the panic message to +/// stderr and trap, but do not attempt to unwind the stack to print a +/// stack trace. +pub const panic: type = if (builtin.is_test or switch (builtin.mode) { + .Debug, .ReleaseSafe => true, + .ReleaseFast, .ReleaseSmall => false, +}) + std.debug.FullPanic(std.debug.defaultPanic) +else + std.debug.FullPanic(tinyPanicImpl); + +/// Prints the panic message to stderr (best-effort) and traps. +/// +/// This intentionally avoids `std.debug.lockStderr`, which routes through +/// `std.Options.debug_io` and would keep the entire `std.Io.Threaded` +/// vtable alive in the binary which takes up hundreds of KB. +/// +/// This is safe to call from any thread (and even from signal handlers): +/// it takes no locks, performs no allocation, and touches no shared +/// mutable state. The message is emitted with a single raw write so that +/// concurrent stderr output doesn't interleave with it. +fn tinyPanicImpl(msg: []const u8, ra: ?usize) noreturn { + @branchHint(.cold); + _ = ra; + + // 256 bytes is enough for most messages, so try that first + // so that we can try to write in a single syscall. + var buf: [256]u8 = undefined; + if (std.fmt.bufPrint( + &buf, + "panic: {s}\n", + .{msg}, + )) |line| { + stderr.write(line); + } else |_| { + stderr.write("panic: "); + stderr.write(msg); + stderr.write("\n"); + } + + // Trap forces a standard crash that embedder-provided debuggers + // or environments can catch. + @trap(); +} + test { // Zig 0.16.0 has made test logging more strict. Now, *anything* that gets // printed to stderr results in a "failed command" message, even if the diff --git a/src/os/main.zig b/src/os/main.zig index 7f9e12cf9..e948144a0 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -28,6 +28,7 @@ pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); pub const shell = @import("shell.zig"); +pub const stderr = @import("stderr.zig"); pub const uri = @import("uri.zig"); // Functions and types @@ -57,6 +58,7 @@ pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = file; + _ = stderr; _ = i18n; _ = path; _ = uri; diff --git a/src/os/stderr.zig b/src/os/stderr.zig new file mode 100644 index 000000000..201bc438b --- /dev/null +++ b/src/os/stderr.zig @@ -0,0 +1,60 @@ +//! Raw, best-effort stderr writing that bypasses `std.Io` entirely. +//! +//! `std.debug.lockStderr` (and everything layered on top of it) routes +//! through `std.Options.debug_io`, whose default implementation is +//! `std.Io.Threaded`. This keeps ~70KB of unreachable code in binaries. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Write bytes to stderr using the most primitive mechanism available +/// for the target. This is best-effort: errors are ignored, since this +/// is only used for diagnostics (logging and panic messages). +/// +/// Freestanding targets (e.g. wasm) have no stderr, so this is a no-op +/// there. +pub fn write(bytes: []const u8) void { + switch (builtin.os.tag) { + .freestanding, .other => {}, + + .windows => { + const windows = std.os.windows; + const handle = windows.peb().ProcessParameters.hStdError; + var iosb: windows.IO_STATUS_BLOCK = undefined; + _ = windows.ntdll.NtWriteFile( + handle, + null, // event + null, // APC routine + null, // APC context + &iosb, + bytes.ptr, + @intCast(bytes.len), + null, // byte offset + null, // key + ); + }, + + else => { + const posix = std.posix; + var i: usize = 0; + while (i < bytes.len) { + const rc = posix.system.write( + posix.STDERR_FILENO, + bytes[i..].ptr, + bytes.len - i, + ); + switch (posix.errno(rc)) { + .SUCCESS => i += @as(usize, @intCast(rc)), + .INTR => continue, + else => return, + } + } + }, + } +} + +test write { + // Smoke test: must not crash. We can't assert on the output without + // capturing stderr, which isn't worth the complexity here. + write(""); +} From 579db418934a5990296b7fd65d1b6b586b46dfc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 10:19:59 -0700 Subject: [PATCH 3/6] libghostty: log to stderr with raw writes, not lockStderr see the prior commit, but most importantly this makes it so we can replace our IO impl from Threaded. --- src/terminal/c/sys.zig | 43 +++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig index 0cf0c6e3b..319f17985 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const terminal_sys = @import("../sys.zig"); +const stderr = @import("../../os/stderr.zig"); const Result = @import("result.zig").Result; /// C: GhosttySysImage @@ -211,8 +212,12 @@ pub fn logFn( /// Formats each message as "[level](scope): message\n". Can be passed /// directly to ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr). /// -/// Uses std.debug.lockStderrWriter for thread-safe, mutex-protected output. -/// On freestanding/wasm targets this is a no-op (no stderr available). +/// Each log line is emitted with a single raw write to stderr, which keeps +/// concurrent log lines from interleaving. We intentionally avoid +/// `std.debug.lockStderr` because it routes through `std.Options.debug_io` +/// and would keep the entire `std.Io.Threaded` vtable alive in the binary +/// (see `os/stderr.zig`). On freestanding/wasm targets this is a no-op +/// (no stderr available). pub fn logStderr( _: ?*anyopaque, level: LogLevel, @@ -233,16 +238,32 @@ pub fn logStderr( .debug => "debug", }; - var buffer: [64]u8 = undefined; - var locked_stderr = std.debug.lockStderr(&buffer); - defer std.debug.unlockStderr(); - nosuspend { - if (scope.len > 0) { - locked_stderr.file_writer.interface.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {}; - } else { - locked_stderr.file_writer.interface.print("[{s}]: {s}\n", .{ level_text, message }) catch {}; - } + // Large enough for a full logFn chunk plus the level/scope prefix. + var buffer: [LogEmitter.buffer_size + 128]u8 = undefined; + const line: ?[]const u8 = if (scope.len > 0) + std.fmt.bufPrint(&buffer, "[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch null + else + std.fmt.bufPrint(&buffer, "[{s}]: {s}\n", .{ level_text, message }) catch null; + if (line) |v| { + stderr.write(v); + return; } + + // The line didn't fit in our buffer (an embedder called us directly + // with a very large message). Fall back to writing the pieces + // separately; interleaving with other threads is possible here but + // this is a best-effort diagnostic path. + stderr.write("["); + stderr.write(level_text); + stderr.write("]"); + if (scope.len > 0) { + stderr.write("("); + stderr.write(scope); + stderr.write(")"); + } + stderr.write(": "); + stderr.write(message); + stderr.write("\n"); } test "set decode_png with null clears" { From 82df79ec8d2c61f3beb3ecfc543ed32cb4aba450 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 11:03:35 -0700 Subject: [PATCH 4/6] libghostty: replace Io.Threaded with custom Io impl (TinyIo) Add a new Io implementation `TinyIo` that only supports the operations we need and doesn't support concurrency. This shrinks the binary size of libghostty by anywhere from ~100KB (macOS) to ~200KB (Linux) and runtime memory requirements by over 256KB (the thread-local storage `std.Io.Threaded` creates plus the 18KB threaded structure is gone). `TinyIo` is POSIX-only: Windows keeps std.Io.Threaded, and on freestanding targets (wasm) TinyIo degrades to std.Io.failing behavior just like before. It is also exported from the Zig module as `ghostty.TinyIo` so Zig embedders can opt into the same size win when constructing terminals. --- src/lib/TinyIo.zig | 1521 +++++++++++++++++++++++++++++++++++ src/lib/main.zig | 1 + src/lib_vt.zig | 11 + src/terminal/c/snapshot.zig | 27 - src/terminal/c/terminal.zig | 22 +- src/terminal/lib.zig | 1 + typos.toml | 3 + 7 files changed, 1552 insertions(+), 34 deletions(-) create mode 100644 src/lib/TinyIo.zig diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig new file mode 100644 index 000000000..b81648d1a --- /dev/null +++ b/src/lib/TinyIo.zig @@ -0,0 +1,1521 @@ +//! TinyIo: a tiny, blocking `std.Io` implementation optimized for +//! binary size. +//! +//! Compared to `std.Io.Threaded`, the binary cost is roughly ~100KB to +//! ~200KB (macOS vs Linux) smaller, and the runtime cost is ~300KB (256KB +//! of TLS plus the ~20KB threaded structure) smaller. +//! +//! Utilizing the built-in Zig `std.Io.Threaded` is easy but due to its +//! vtable architecture, the linker can't prune uncalled functions, meaning +//! you have to pay for all the code for every op. Plus, at runtime, Threaded +//! requires 256KB of thread-local storage, plus the structure itself is +//! very large. +//! +//! TinyIo implements exactly the operations we need as plain blocking +//! syscalls. It doesn't support cancelation. It doesn't support concurrency +//! operations, but all our APIs are direct syscalls so it supports the +//! concurrency of calls that those syscalls support (which is usually +//! safe on POSIX systems). +//! +//! A lot of the direct syscall code is cribbed from Zig's std.posix. + +const TinyIo = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const Io = std.Io; +const File = Io.File; +const Dir = Io.Dir; +const Threaded = std.Io.Threaded; + +/// True if this platform has a real TinyIo implementation. On +/// unsupported platforms `io()` still works but every operation fails +/// like `std.Io.failing`. +pub const supported: bool = switch (builtin.os.tag) { + .windows, .wasi, .freestanding, .other, .uefi => false, + else => true, +}; + +/// Initialize a TinyIo. TinyIo is stateless (zero-sized), so this exists +/// purely to mirror the `std.Io.Threaded` initialization and usage shape, +/// making the two easy to swap. +pub const init: TinyIo = .{}; + +/// Returns the `std.Io` interface for this implementation. +pub fn io(self: TinyIo) Io { + _ = self; + return .{ + .userdata = null, + .vtable = &vtable, + }; +} + +const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ + .crashHandler = Io.noCrashHandler, + + // `noAsync` runs the task synchronously and returns null, which means + // `await`/`cancel` can never be called. `concurrent` reports that + // concurrency is unavailable, which callers must handle anyway. + .async = Io.noAsync, + .concurrent = Io.failingConcurrent, + .await = Io.unreachableAwait, + .cancel = Io.unreachableCancel, + + .groupAsync = Io.noGroupAsync, + .groupConcurrent = Io.failingGroupConcurrent, + .groupAwait = Io.unreachableGroupAwait, + .groupCancel = Io.unreachableGroupCancel, + + // Cancelation is never requested (there is no async), but these are + // benign no-ops instead of `unreachable` since generic std code may + // toggle cancel protection around operations. + .recancel = recancel, + .swapCancelProtection = swapCancelProtection, + .checkCancel = checkCancel, + + // Real futexes: `std.Io.Mutex` (used by kitty graphics storage) parks + // on these when contended. + .futexWait = futexWait, + .futexWaitUncancelable = futexWaitUncancelable, + .futexWake = futexWake, + + // `operate` carries the streaming file reads used by `File.Reader`. + .operate = operate, + .batchAwaitAsync = Io.unreachableBatchAwaitAsync, + .batchAwaitConcurrent = Io.unreachableBatchAwaitConcurrent, + .batchCancel = Io.unreachableBatchCancel, + + .dirCreateDir = Io.failingDirCreateDir, + .dirCreateDirPath = Io.failingDirCreateDirPath, + .dirCreateDirPathOpen = Io.failingDirCreateDirPathOpen, + .dirOpenDir = Io.failingDirOpenDir, + .dirStat = Io.failingDirStat, + .dirStatFile = Io.failingDirStatFile, + .dirAccess = Io.failingDirAccess, + .dirCreateFile = Io.failingDirCreateFile, + .dirCreateFileAtomic = Io.failingDirCreateFileAtomic, + .dirOpenFile = dirOpenFile, + .dirClose = dirClose, + .dirRead = Io.noDirRead, + .dirRealPath = Io.failingDirRealPath, + .dirRealPathFile = dirRealPathFile, + .dirDeleteFile = dirDeleteFile, + .dirDeleteDir = Io.failingDirDeleteDir, + .dirRename = Io.failingDirRename, + .dirRenamePreserve = Io.failingDirRenamePreserve, + .dirSymLink = Io.failingDirSymLink, + .dirReadLink = Io.failingDirReadLink, + .dirSetOwner = Io.failingDirSetOwner, + .dirSetFileOwner = Io.failingDirSetFileOwner, + .dirSetPermissions = Io.failingDirSetPermissions, + .dirSetFilePermissions = Io.failingDirSetFilePermissions, + .dirSetTimestamps = Io.noDirSetTimestamps, + .dirHardLink = Io.failingDirHardLink, + + .fileStat = fileStat, + .fileLength = fileLength, + .fileClose = fileClose, + .fileWritePositional = Io.failingFileWritePositional, + .fileWriteFileStreaming = Io.noFileWriteFileStreaming, + .fileWriteFilePositional = Io.noFileWriteFilePositional, + .fileReadPositional = fileReadPositional, + .fileSeekBy = fileSeekBy, + .fileSeekTo = fileSeekTo, + .fileSync = Io.failingFileSync, + .fileIsTty = Io.unreachableFileIsTty, + .fileEnableAnsiEscapeCodes = Io.unreachableFileEnableAnsiEscapeCodes, + .fileSupportsAnsiEscapeCodes = Io.unreachableFileSupportsAnsiEscapeCodes, + .fileSetLength = Io.failingFileSetLength, + .fileSetOwner = Io.failingFileSetOwner, + .fileSetPermissions = Io.failingFileSetPermissions, + .fileSetTimestamps = Io.noFileSetTimestamps, + .fileLock = Io.failingFileLock, + .fileTryLock = Io.failingFileTryLock, + .fileUnlock = Io.unreachableFileUnlock, + .fileDowngradeLock = Io.failingFileDowngradeLock, + .fileRealPath = fileRealPath, + .fileHardLink = Io.failingFileHardLink, + + .fileMemoryMapCreate = Io.failingFileMemoryMapCreate, + .fileMemoryMapDestroy = Io.unreachableFileMemoryMapDestroy, + .fileMemoryMapSetLength = Io.unreachableFileMemoryMapSetLength, + .fileMemoryMapRead = Io.unreachableFileMemoryMapRead, + .fileMemoryMapWrite = Io.unreachableFileMemoryMapWrite, + + .processExecutableOpen = Io.failingProcessExecutableOpen, + .processExecutablePath = Io.failingProcessExecutablePath, + .lockStderr = Io.unreachableLockStderr, + .tryLockStderr = Io.noTryLockStderr, + .unlockStderr = Io.unreachableUnlockStderr, + .processCurrentPath = Io.failingProcessCurrentPath, + .processSetCurrentDir = Io.failingProcessSetCurrentDir, + .processSetCurrentPath = Io.failingProcessSetCurrentPath, + .processReplace = Io.failingProcessReplace, + .processReplacePath = Io.failingProcessReplacePath, + .processSpawn = Io.failingProcessSpawn, + .processSpawnPath = Io.failingProcessSpawnPath, + .childWait = Io.unreachableChildWait, + .childKill = Io.unreachableChildKill, + + .progressParentFile = Io.failingProgressParentFile, + + .random = Io.noRandom, + .randomSecure = Io.failingRandomSecure, + + .now = Io.noNow, + .clockResolution = Io.failingClockResolution, + .sleep = Io.noSleep, + + .netListenIp = Io.failingNetListenIp, + .netAccept = Io.failingNetAccept, + .netBindIp = Io.failingNetBindIp, + .netConnectIp = Io.failingNetConnectIp, + .netListenUnix = Io.failingNetListenUnix, + .netConnectUnix = Io.failingNetConnectUnix, + .netSocketCreatePair = Io.failingNetSocketCreatePair, + .netSend = Io.failingNetSend, + .netRead = Io.failingNetRead, + .netWrite = Io.failingNetWrite, + .netWriteFile = Io.failingNetWriteFile, + .netClose = Io.unreachableNetClose, + .netShutdown = Io.failingNetShutdown, + .netInterfaceNameResolve = Io.failingNetInterfaceNameResolve, + .netInterfaceName = Io.unreachableNetInterfaceName, + .netLookup = Io.failingNetLookup, +}; + +// 64-bit offset syscall selection, same as `std.Io.Threaded`. +const openat_sym = if (posix.lfs64_abi) posix.system.openat64 else posix.system.openat; +const fstat_sym = if (posix.lfs64_abi) posix.system.fstat64 else posix.system.fstat; +const fstatat_sym = if (posix.lfs64_abi) posix.system.fstatat64 else posix.system.fstatat; +const lseek_sym = if (posix.lfs64_abi) posix.system.lseek64 else posix.system.lseek; +const preadv_sym = if (posix.lfs64_abi) posix.system.preadv64 else posix.system.preadv; + +const have_preadv = switch (builtin.os.tag) { + .haiku => false, + else => true, +}; + +fn recancel(_: ?*anyopaque) void {} + +fn swapCancelProtection( + _: ?*anyopaque, + new: Io.CancelProtection, +) Io.CancelProtection { + // Stateless: there is no async, so cancelation can never be requested + // and the protection state is meaningless. Callers save this return + // value only to restore it via another call to this function. + _ = new; + return .blocked; +} + +fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} + +fn closeFd(fd: posix.fd_t) void { + // Never retry close on EINTR: POSIX leaves the fd state unspecified + // and Linux always closes it, so retrying risks closing an unrelated + // fd opened by another thread. + _ = posix.system.close(fd); +} + +fn dirOpenFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + options: Dir.OpenFileOptions, +) File.OpenError!File { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + // Nothing in the terminal locks files. Implementing this requires + // flock fallbacks (see std.Io.Threaded); report it as unsupported. + if (options.lock != .none) return error.FileLocksUnsupported; + + var flags: posix.O = .{ + .ACCMODE = switch (options.mode) { + .read_only => .RDONLY, + .write_only => .WRONLY, + .read_write => .RDWR, + }, + .NOFOLLOW = !options.follow_symlinks, + }; + if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; + if (@hasField(posix.O, "LARGEFILE")) flags.LARGEFILE = true; + if (@hasField(posix.O, "NOCTTY")) flags.NOCTTY = !options.allow_ctty; + if (@hasField(posix.O, "PATH")) flags.PATH = options.path_only; + if (@hasField(posix.O, "RESOLVE_BENEATH")) flags.RESOLVE_BENEATH = options.resolve_beneath; + + const mode: posix.mode_t = 0; + const fd: posix.fd_t = while (true) { + const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); + switch (posix.errno(rc)) { + .SUCCESS => break @intCast(rc), + .INTR => continue, + .INVAL => return error.BadPathName, + .ACCES => return error.AccessDenied, + .FBIG => return error.FileTooBig, + .OVERFLOW => return error.FileTooBig, + .ISDIR => return error.IsDir, + .LOOP => return error.SymLinkLoop, + .MFILE => return error.ProcessFdQuotaExceeded, + .NAMETOOLONG => return error.NameTooLong, + .NFILE => return error.SystemFdQuotaExceeded, + .NODEV => return error.NoDevice, + .NOENT => return error.FileNotFound, + .SRCH => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOSPC => return error.NoSpaceLeft, + .NOTDIR => return error.NotDir, + .PERM => return error.PermissionDenied, + .EXIST => return error.PathAlreadyExists, + .BUSY => return error.DeviceBusy, + .OPNOTSUPP => return error.FileLocksUnsupported, + .AGAIN => return error.WouldBlock, + .TXTBSY => return error.FileBusy, + .NXIO => return error.NoDevice, + .ROFS => return error.ReadOnlyFileSystem, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + }; + errdefer closeFd(fd); + + const file: File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + + if (!options.allow_directory) { + const is_dir = is_dir: { + const stat = fileStat(null, file) catch |err| switch (err) { + // Directory-ness is unknown or unknowable. + error.Streaming => break :is_dir false, + else => |e| return e, + }; + break :is_dir stat.kind == .directory; + }; + if (is_dir) return error.IsDir; + } + + return file; +} + +fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { + for (dirs) |dir| closeFd(dir.handle); +} + +fn fileClose(_: ?*anyopaque, files: []const File) void { + for (files) |file| closeFd(file.handle); +} + +fn fileStat(_: ?*anyopaque, file: File) File.StatError!File.Stat { + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + while (true) { + var statx = std.mem.zeroes(linux.Statx); + switch (linux.errno(linux.statx( + file.handle, + "", + linux.AT.EMPTY_PATH, + Threaded.linux_statx_request, + &statx, + ))) { + .SUCCESS => return Threaded.statFromLinux(&statx), + .INTR => continue, + .NOMEM => return error.SystemResources, + else => |err| return posix.unexpectedErrno(err), + } + } + } + + while (true) { + var stat = std.mem.zeroes(posix.Stat); + switch (posix.errno(fstat_sym(file.handle, &stat))) { + .SUCCESS => return Threaded.statFromPosix(&stat), + .INTR => continue, + .NOMEM => return error.SystemResources, + .ACCES => return error.AccessDenied, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileLength(userdata: ?*anyopaque, file: File) File.LengthError!u64 { + const stat = try fileStat(userdata, file); + return stat.size; +} + +/// Gathers non-empty buffers into iovecs. Returns an empty slice if there +/// is nothing to read into. +fn buffersToIovecs( + data: []const []u8, + iovecs_buffer: *[Threaded.max_iovecs_len]posix.iovec, +) []posix.iovec { + var i: usize = 0; + for (data) |buf| { + if (iovecs_buffer.len - i == 0) break; + if (buf.len != 0) { + iovecs_buffer[i] = .{ .base = buf.ptr, .len = buf.len }; + i += 1; + } + } + return iovecs_buffer[0..i]; +} + +fn fileReadPositional( + _: ?*anyopaque, + file: File, + data: []const []u8, + offset: u64, +) File.ReadPositionalError!usize { + var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; + const dest = buffersToIovecs(data, &iovecs_buffer); + if (dest.len == 0) return 0; + + while (true) { + const rc = if (comptime have_preadv) + preadv_sym(file.handle, dest.ptr, @intCast(dest.len), @bitCast(offset)) + else + posix.system.pread(file.handle, dest[0].base, dest[0].len, @bitCast(offset)); + switch (posix.errno(rc)) { + .SUCCESS => return @bitCast(rc), + .INTR, .TIMEDOUT => continue, + .NXIO => return error.Unseekable, + .SPIPE => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .NOBUFS => return error.SystemResources, + .NOMEM => return error.SystemResources, + .AGAIN => return error.WouldBlock, + .IO => return error.InputOutput, + .ISDIR => return error.IsDir, + .BADF => return error.NotOpenForReading, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileReadStreaming( + file: File, + data: []const []u8, +) Io.Operation.FileReadStreaming.Error!usize { + var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; + const dest = buffersToIovecs(data, &iovecs_buffer); + if (dest.len == 0) return 0; + + while (true) { + const rc = posix.system.readv(file.handle, dest.ptr, @intCast(dest.len)); + switch (posix.errno(rc)) { + .SUCCESS => { + if (rc == 0) return error.EndOfStream; + return @intCast(rc); + }, + .INTR, .TIMEDOUT => continue, + .AGAIN => return error.WouldBlock, + .IO => return error.InputOutput, + .ISDIR => return error.IsDir, + .NOBUFS => return error.SystemResources, + .NOMEM => return error.SystemResources, + .NOTCONN => return error.SocketUnconnected, + .CONNRESET => return error.ConnectionResetByPeer, + .BADF => return error.NotOpenForReading, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn operate( + userdata: ?*anyopaque, + operation: Io.Operation, +) Io.Cancelable!Io.Operation.Result { + switch (operation) { + // Streaming file reads are how `File.Reader` consumes files that + // don't support positional reads (and the fallback path in + // general). + .file_read_streaming => |o| return .{ + .file_read_streaming = fileReadStreaming(o.file, o.data), + }, + + // Everything else (streaming writes, ioctls, socket receives) is + // unused by the terminal; fail like `Io.failing` does. + else => return Io.failingOperate(userdata, operation), + } +} + +fn fileSeekBy(_: ?*anyopaque, file: File, offset: i64) File.SeekError!void { + while (true) { + const rc = lseek_sym(file.handle, offset, posix.SEEK.CUR); + switch (posix.errno(rc)) { + .SUCCESS => return, + .INTR => continue, + .INVAL => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .SPIPE => return error.Unseekable, + .NXIO => return error.Unseekable, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileSeekTo(_: ?*anyopaque, file: File, offset: u64) File.SeekError!void { + while (true) { + const rc = lseek_sym(file.handle, @bitCast(offset), posix.SEEK.SET); + switch (posix.errno(rc)) { + .SUCCESS => return, + .INTR => continue, + .INVAL => return error.Unseekable, + .OVERFLOW => return error.Unseekable, + .SPIPE => return error.Unseekable, + .NXIO => return error.Unseekable, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +fn fileRealPath( + _: ?*anyopaque, + file: File, + out_buffer: []u8, +) File.RealPathError!usize { + return realPathFd(file.handle, out_buffer); +} + +fn realPathFd(fd: posix.fd_t, out_buffer: []u8) File.RealPathError!usize { + switch (builtin.os.tag) { + .dragonfly, .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + var sufficient_buffer: [posix.PATH_MAX]u8 = undefined; + @memset(&sufficient_buffer, 0); + while (true) { + switch (posix.errno(posix.system.fcntl(fd, posix.F.GETPATH, &sufficient_buffer))) { + .SUCCESS => break, + .INTR => continue, + .ACCES => return error.AccessDenied, + .BADF => return error.FileNotFound, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOSPC => return error.NameTooLong, + .RANGE => return error.NameTooLong, + else => |err| return posix.unexpectedErrno(err), + } + } + const n = std.mem.indexOfScalar(u8, &sufficient_buffer, 0) orelse sufficient_buffer.len; + if (n > out_buffer.len) return error.NameTooLong; + @memcpy(out_buffer[0..n], sufficient_buffer[0..n]); + return n; + }, + + .linux, .serenity, .illumos => { + var procfs_buf: ["/proc/self/path/-2147483648\x00".len]u8 = undefined; + const template = if (builtin.os.tag == .illumos) "/proc/self/path/{d}" else "/proc/self/fd/{d}"; + const proc_path = std.fmt.bufPrintSentinel(&procfs_buf, template, .{fd}, 0) catch unreachable; + while (true) { + const rc = posix.system.readlink(proc_path, out_buffer.ptr, out_buffer.len); + switch (posix.errno(rc)) { + .SUCCESS => return @bitCast(rc), + .INTR => continue, + .ACCES => return error.AccessDenied, + .IO => return error.FileSystem, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOTDIR => return error.NotDir, + else => |err| return posix.unexpectedErrno(err), + } + } + }, + + .freebsd => { + var k_file: std.c.kinfo_file = undefined; + k_file.structsize = std.c.KINFO_FILE_SIZE; + while (true) { + switch (posix.errno(std.c.fcntl(fd, std.c.F.KINFO, @intFromPtr(&k_file)))) { + .SUCCESS => break, + .INTR => continue, + .BADF => return error.FileNotFound, + else => |err| return posix.unexpectedErrno(err), + } + } + const len = std.mem.findScalar(u8, &k_file.path, 0) orelse k_file.path.len; + if (len == 0) return error.NameTooLong; + @memcpy(out_buffer[0..len], k_file.path[0..len]); + return len; + }, + + else => return error.OperationUnsupported, + } +} + +fn dirRealPathFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + out_buffer: []u8, +) Dir.RealPathFileError!usize { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + if (builtin.link_libc and dir.handle == posix.AT.FDCWD) { + if (out_buffer.len < posix.PATH_MAX) return error.NameTooLong; + while (true) { + if (std.c.realpath(sub_path_posix, out_buffer.ptr)) |redundant_pointer| { + std.debug.assert(redundant_pointer == out_buffer.ptr); + return std.mem.indexOfScalar(u8, out_buffer, 0) orelse out_buffer.len; + } + switch (@as(posix.E, @enumFromInt(std.c._errno().*))) { + .INTR => continue, + .ACCES => return error.AccessDenied, + .NOENT => return error.FileNotFound, + .OPNOTSUPP => return error.OperationUnsupported, + .NOTDIR => return error.NotDir, + .NAMETOOLONG => return error.NameTooLong, + .LOOP => return error.SymLinkLoop, + .IO => return error.InputOutput, + else => |err| return posix.unexpectedErrno(err), + } + } + } + + // Fallback: open the path and resolve the fd. Used for non-cwd + // directory handles (which the terminal itself never passes) and + // non-libc builds. + var flags: posix.O = .{}; + if (@hasField(posix.O, "NONBLOCK")) flags.NONBLOCK = true; + if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; + if (@hasField(posix.O, "PATH")) flags.PATH = true; + + const mode: posix.mode_t = 0; + const fd: posix.fd_t = while (true) { + const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); + switch (posix.errno(rc)) { + .SUCCESS => break @intCast(rc), + .INTR => continue, + .INVAL => return error.BadPathName, + .ACCES => return error.AccessDenied, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOMEM => return error.SystemResources, + .NOTDIR => return error.NotDir, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + }; + defer closeFd(fd); + + return realPathFd(fd, out_buffer); +} + +fn dirDeleteFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, +) Dir.DeleteFileError!void { + var path_buffer: [posix.PATH_MAX]u8 = undefined; + const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); + + while (true) { + switch (posix.errno(posix.system.unlinkat(dir.handle, sub_path_posix, 0))) { + .SUCCESS => return, + .INTR => continue, + // Some systems return EPERM when trying to delete a directory; + // stat to disambiguate from a real permission error. + .PERM => switch (builtin.os.tag) { + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd, .illumos => { + var st = std.mem.zeroes(posix.Stat); + while (true) { + switch (posix.errno(fstatat_sym( + dir.handle, + sub_path_posix, + &st, + posix.AT.SYMLINK_NOFOLLOW, + ))) { + .SUCCESS => break, + .INTR => continue, + else => return error.PermissionDenied, + } + } + const is_dir = st.mode & posix.S.IFMT == posix.S.IFDIR; + return if (is_dir) error.IsDir else error.PermissionDenied; + }, + else => return error.PermissionDenied, + }, + .ACCES => return error.AccessDenied, + .BUSY => return error.FileBusy, + .IO => return error.FileSystem, + .ISDIR => return error.IsDir, + .LOOP => return error.SymLinkLoop, + .NAMETOOLONG => return error.NameTooLong, + .NOENT => return error.FileNotFound, + .NOTDIR => return error.NotDir, + .NOMEM => return error.SystemResources, + .ROFS => return error.ReadOnlyFileSystem, + .ILSEQ => return error.BadPathName, + else => |err| return posix.unexpectedErrno(err), + } + } +} + +/// Convert an `Io.Timeout` to relative nanoseconds without consulting a +/// clock. Deadlines can't be resolved without `now` support; treat them +/// as a short poll, which is valid because futex waits are allowed to +/// wake spuriously (callers must re-check their condition and retry). +fn timeoutToNs(timeout: Io.Timeout) ?u64 { + return switch (timeout) { + .none => null, + .duration => |d| @intCast(@max(0, d.raw.toNanoseconds())), + .deadline => 10 * std.time.ns_per_ms, + }; +} + +fn futexWait( + userdata: ?*anyopaque, + ptr: *const u32, + expected: u32, + timeout: Io.Timeout, +) Io.Cancelable!void { + _ = userdata; + futexWaitInner(ptr, expected, timeoutToNs(timeout)); +} + +fn futexWaitUncancelable(userdata: ?*anyopaque, ptr: *const u32, expected: u32) void { + _ = userdata; + futexWaitInner(ptr, expected, null); +} + +fn futexWaitInner(ptr: *const u32, expected: u32, timeout_ns: ?u64) void { + @branchHint(.cold); + + if (builtin.single_threaded) unreachable; // nobody would ever wake us + + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + var ts_buffer: linux.timespec = undefined; + const ts: ?*linux.timespec = if (timeout_ns) |ns| ts: { + ts_buffer = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + break :ts &ts_buffer; + } else null; + const rc = linux.futex_4arg(ptr, .{ .cmd = .WAIT, .private = true }, expected, ts); + switch (linux.errno(rc)) { + .SUCCESS => {}, // notified by wake + .INTR => {}, // caller's responsibility to retry + .AGAIN => {}, // ptr.* != expected + .INVAL => {}, // possibly timeout overflow + .TIMEDOUT => {}, + else => {}, // spurious wakeup; caller retries + } + }, + + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + const c = std.c; + const flags: c.UL = .{ + .op = .COMPARE_AND_WAIT, + .NO_ERRNO = true, + }; + const us: u32 = us: { + const ns = timeout_ns orelse break :us 0; // 0 means infinite + const us = std.math.lossyCast(u32, ns / std.time.ns_per_us); + break :us if (us == 0) 1 else us; + }; + const status = c.__ulock_wait(flags, ptr, expected, us); + if (status >= 0) return; + switch (@as(c.E, @enumFromInt(-status))) { + .INTR => {}, // spurious wake + .FAULT => {}, // futex address paged out; caller retries + .TIMEDOUT => {}, + else => {}, // spurious wakeup; caller retries + } + }, + + .freebsd => { + const flags = @intFromEnum(std.c.UMTX_OP.WAIT_UINT_PRIVATE); + var tm_size: usize = 0; + var tm: std.c._umtx_time = undefined; + var tm_ptr: ?*const std.c._umtx_time = null; + if (timeout_ns) |ns| { + tm_ptr = &tm; + tm_size = @sizeOf(@TypeOf(tm)); + tm.flags = 0; // relative time + tm.clockid = .MONOTONIC; + tm.timeout = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + } + _ = std.c._umtx_op( + @intFromPtr(ptr), + flags, + @as(c_ulong, expected), + tm_size, + @intFromPtr(tm_ptr), + ); + }, + + else => { + // Portable fallback: futex waits may wake spuriously, so a + // bounded sleep is a valid (if inefficient) implementation. + // Contention is not expected in libghostty-vt's threading + // model, so this is effectively never reached. + if (@atomicLoad(u32, ptr, .seq_cst) != expected) return; + const ns = @min(timeout_ns orelse std.time.ns_per_ms, std.time.ns_per_ms); + const ts: posix.timespec = .{ + .sec = 0, + .nsec = @intCast(ns), + }; + _ = posix.system.nanosleep(&ts, null); + }, + } +} + +fn futexWake(userdata: ?*anyopaque, ptr: *const u32, max_waiters: u32) void { + @branchHint(.cold); + _ = userdata; + + if (builtin.single_threaded) return; // nothing to wake up + + switch (builtin.os.tag) { + .linux => { + const linux = std.os.linux; + _ = linux.futex_3arg( + ptr, + .{ .cmd = .WAKE, .private = true }, + @min(max_waiters, std.math.maxInt(i32)), + ); + }, + + .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { + const c = std.c; + const flags: c.UL = .{ + .op = .COMPARE_AND_WAIT, + .NO_ERRNO = true, + .WAKE_ALL = max_waiters > 1, + }; + while (true) { + const status = c.__ulock_wake(flags, ptr, 0); + if (status >= 0) return; + switch (@as(c.E, @enumFromInt(-status))) { + .INTR, .CANCELED => continue, // spurious wake + else => return, + } + } + }, + + .freebsd => { + _ = std.c._umtx_op( + @intFromPtr(ptr), + @intFromEnum(std.c.UMTX_OP.WAKE_PRIVATE), + @min(max_waiters, std.math.maxInt(c_ulong)), + 0, + 0, + ); + }, + + // Portable fallback waiters poll with a timeout; nothing to do. + else => {}, + } +} + +test "read a file through File.Reader" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const contents = "hello minimal test_io\n" ** 100; + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "test.txt", + .data = contents, + }); + + // Open through our Io. The Dir handle is a plain fd, so it is usable + // across Io implementations. + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "test.txt", .{}); + defer file.close(test_io); + + // Stat through our Io. + const stat = try file.stat(test_io); + try testing.expectEqual(@as(u64, contents.len), stat.size); + try testing.expectEqual(File.Kind.file, stat.kind); + + // fileLength. + try testing.expectEqual(@as(u64, contents.len), try file.length(test_io)); + + // Read it all back through File.Reader (exercises positional reads + // and streaming fallbacks). + var buf: [64]u8 = undefined; + var reader = file.reader(test_io, &buf); + var list: std.ArrayList(u8) = .empty; + defer list.deinit(testing.allocator); + try reader.interface.appendRemaining(testing.allocator, &list, .unlimited); + try testing.expectEqualStrings(contents, list.items); +} + +test "seek" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "seek.txt", + .data = "0123456789", + }); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "seek.txt", .{}); + defer file.close(test_io); + + // Exercise our seek and streaming read implementations directly at + // the vtable level; the higher-level File.Reader seek plumbing has + // its own buffering behaviors that are independent of the Io + // implementation. + var out: [4]u8 = undefined; + var slices = [_][]u8{&out}; + + try test_io.vtable.fileSeekTo(test_io.userdata, file, 6); + var n = try file.readStreaming(test_io, &slices); + try testing.expectEqualStrings("6789", out[0..n]); + + // Seek back relative and re-read. + try test_io.vtable.fileSeekBy(test_io.userdata, file, -8); + n = try file.readStreaming(test_io, &slices); + try testing.expectEqualStrings("2345", out[0..n]); + + // Positional reads are independent of the seek position. + var pslices = [_][]u8{&out}; + n = try test_io.vtable.fileReadPositional(test_io.userdata, file, &pslices, 1); + try testing.expectEqualStrings("1234", out[0..n]); +} + +test "realPath and deleteFile" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Only platforms with a real implementation. + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "real.txt", + .data = "x", + }); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "real.txt", .{}); + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try file.realPath(test_io, &path_buf)]; + try testing.expect(std.mem.endsWith(u8, path, "real.txt")); + file.close(test_io); + + // dirRealPathFile via absolute path from cwd. + var path_buf2: [std.fs.max_path_bytes]u8 = undefined; + const path2 = path_buf2[0..try Dir.cwd().realPathFile(test_io, path, &path_buf2)]; + try testing.expectEqualStrings(path, path2); + + // Delete it through our Io, verify it is gone. + try dir.deleteFile(test_io, "real.txt"); + try testing.expectError(error.FileNotFound, dir.openFile(test_io, "real.txt", .{})); +} + +test "openFile of a directory returns IsDir" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.createDir(testing.io, "sub", .default_dir); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + try testing.expectError(error.IsDir, dir.openFile(test_io, "sub", .{ + .allow_directory = false, + })); +} + +test "Io.Mutex through TinyIo" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + // Contended and uncontended lock/unlock; exercises the futex ops. + var mutex: Io.Mutex = .init; + mutex.lockUncancelable(test_io); + mutex.unlock(test_io); + + // Wake with no waiters must be a no-op. + var word: u32 = 0; + test_io.vtable.futexWake(test_io.userdata, &word, 1); + + // Wait with a non-matching expected value must return immediately. + test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); +} + +test "unused operations fail gracefully" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + try testing.expectError( + error.NoSpaceLeft, + dir.createFile(test_io, "nope.txt", .{}), + ); +} + +test "openFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "edge.txt", + .data = "edge", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // File locking is unimplemented and must be reported, not ignored. + try testing.expectError(error.FileLocksUnsupported, dir.openFile( + test_io, + "edge.txt", + .{ .lock = .shared }, + )); + + // NUL bytes never form a valid POSIX path. + try testing.expectError(error.BadPathName, dir.openFile( + test_io, + "bad\x00path", + .{}, + )); + + // Paths that can't fit in PATH_MAX must not be silently truncated. + const long_name = "a" ** (std.fs.max_path_bytes + 1); + try testing.expectError(error.NameTooLong, dir.openFile( + test_io, + long_name, + .{}, + )); + + // A path component that is a file, not a directory. + try testing.expectError(error.NotDir, dir.openFile( + test_io, + "edge.txt/child", + .{}, + )); + + // Directories may be opened when allowed (the default) and stat + // reports their kind. + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + var dir_file = try dir.openFile(test_io, "subdir", .{}); + const dir_stat = try dir_file.stat(test_io); + try testing.expectEqual(File.Kind.directory, dir_stat.kind); + dir_file.close(test_io); + + // Write modes translate to the right ACCMODE flags; TinyIo can't + // write but opening for write must succeed. + var wfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .write_only }); + wfile.close(test_io); + var rwfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .read_write }); + rwfile.close(test_io); +} + +test "openFile symlink handling" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Platforms where we know both symlink creation (via the testing Io) + // and O_NOFOLLOW behave as expected. + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "target.txt", + .data = "target", + }); + try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Following symlinks (the default) opens the target... + var file = try dir.openFile(test_io, "link.txt", .{}); + try testing.expectEqual(@as(u64, "target".len), try file.length(test_io)); + + // ...and realPath resolves through the link to the target. This is + // the property the Kitty graphics path validation relies on. + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try file.realPath(test_io, &path_buf)]; + try testing.expect(std.mem.endsWith(u8, path, "target.txt")); + file.close(test_io); + + // Refusing to follow symlinks fails with SymLinkLoop. + try testing.expectError(error.SymLinkLoop, dir.openFile( + test_io, + "link.txt", + .{ .follow_symlinks = false }, + )); +} + +test "positional reads at and beyond EOF return zero" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "eof.txt", + .data = "0123456789", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "eof.txt", .{}); + defer file.close(test_io); + + var out: [4]u8 = undefined; + var slices = [_][]u8{&out}; + + // At EOF and past EOF: the vtable contract is "returns 0 if reading + // at or past the end". + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 10, + )); + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 9999, + )); + + // No buffers and only-empty buffers read nothing without a syscall. + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &.{}, + 0, + )); + var empty = [_][]u8{ &.{}, &.{} }; + try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &empty, + 0, + )); +} + +test "vectored reads scatter across buffers" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "vec.txt", + .data = "0123456789abcdef", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "vec.txt", .{}); + defer file.close(test_io); + + // Scatter a positional read across multiple buffers, with empty + // buffers interleaved (they must be skipped). + var a: [4]u8 = undefined; + var b: [2]u8 = undefined; + var c: [6]u8 = undefined; + var slices = [_][]u8{ &a, &.{}, &b, &c }; + const n = try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &slices, + 0, + ); + try testing.expectEqual(@as(usize, 12), n); + try testing.expectEqualStrings("0123", &a); + try testing.expectEqualStrings("45", &b); + try testing.expectEqualStrings("6789ab", &c); + + // More buffers than max_iovecs_len: reads are truncated to the + // first max_iovecs_len non-empty buffers (partial reads are allowed + // by the vtable contract; callers retry). + comptime std.debug.assert(Threaded.max_iovecs_len < 16); + var bytes: [16][1]u8 = undefined; + var many: [16][]u8 = undefined; + for (&many, &bytes) |*s, *byte| s.* = byte; + const n2 = try test_io.vtable.fileReadPositional( + test_io.userdata, + file, + &many, + 0, + ); + try testing.expectEqual(@as(usize, Threaded.max_iovecs_len), n2); + for (bytes[0..n2], "0123456789abcdef"[0..n2]) |got, want| { + try testing.expectEqual(want, got[0]); + } +} + +test "streaming reads: EndOfStream and scatter" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "stream.txt", + .data = "streaming!", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "stream.txt", .{}); + defer file.close(test_io); + + // Scatter a streaming read. + var a: [6]u8 = undefined; + var b: [4]u8 = undefined; + var slices = [_][]u8{ &a, &b }; + try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); + try testing.expectEqualStrings("stream", &a); + try testing.expectEqualStrings("ing!", &b); + + // Reading again at EOF is a stream end, not a zero-length success. + try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); + + // Empty destinations read nothing. + try testing.expectEqual(@as(usize, 0), try file.readStreaming(test_io, &.{})); + + // Seeking beyond EOF is legal; the next streaming read hits EOF. + try test_io.vtable.fileSeekTo(test_io.userdata, file, 9999); + try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); + + // And seeking back to zero re-reads from the start. + try test_io.vtable.fileSeekTo(test_io.userdata, file, 0); + try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); + try testing.expectEqualStrings("stream", &a); +} + +test "pipes: streaming works, positional and seek are Unseekable" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var fds: [2]posix.fd_t = undefined; + switch (posix.errno(posix.system.pipe(&fds))) { + .SUCCESS => {}, + else => return error.SkipZigTest, + } + defer closeFd(fds[1]); + const read_end: File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + defer read_end.close(test_io); + + const msg = "through the pipe"; + try testing.expectEqual( + @as(isize, msg.len), + @as(isize, @intCast(posix.system.write(fds[1], msg, msg.len))), + ); + + // Streaming reads work on unseekable files; this is the fallback + // File.Reader depends on when positional reads report Unseekable. + var buf: [msg.len]u8 = undefined; + var slices = [_][]u8{&buf}; + try testing.expectEqual(@as(usize, msg.len), try read_end.readStreaming(test_io, &slices)); + try testing.expectEqualStrings(msg, &buf); + + // Positional reads and seeks must report Unseekable so callers can + // fall back to streaming. + try testing.expectError(error.Unseekable, test_io.vtable.fileReadPositional( + test_io.userdata, + read_end, + &slices, + 0, + )); + try testing.expectError(error.Unseekable, test_io.vtable.fileSeekTo( + test_io.userdata, + read_end, + 0, + )); + try testing.expectError(error.Unseekable, test_io.vtable.fileSeekBy( + test_io.userdata, + read_end, + 1, + )); +} + +test "operate delegates non-read operations to failing stubs" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "w.txt", + .data = "x", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + var file = try dir.openFile(test_io, "w.txt", .{ .mode = .write_only }); + defer file.close(test_io); + + const result = try test_io.vtable.operate(test_io.userdata, .{ + .file_write_streaming = .{ + .file = file, + .data = &.{"nope"}, + }, + }); + try testing.expectError(error.InputOutput, result.file_write_streaming); +} + +test "dirRealPathFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + switch (builtin.os.tag) { + .macos, .ios, .linux, .freebsd => {}, + else => return error.SkipZigTest, + } + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "real.txt", + .data = "x", + }); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Resolve the canonical path through an open file for reference. + var file = try dir.openFile(test_io, "real.txt", .{}); + var want_buf: [std.fs.max_path_bytes]u8 = undefined; + const want = want_buf[0..try file.realPath(test_io, &want_buf)]; + file.close(test_io); + + // A non-cwd directory handle exercises the open-then-resolve + // fallback branch rather than libc realpath. + var got_buf: [std.fs.max_path_bytes]u8 = undefined; + const got = got_buf[0..try test_io.vtable.dirRealPathFile( + test_io.userdata, + dir, + "real.txt", + &got_buf, + )]; + try testing.expectEqualStrings(want, got); + + // Missing paths report FileNotFound (libc realpath branch, via an + // absolute path anchored at cwd). + var missing_buf: [std.fs.max_path_bytes]u8 = undefined; + const missing = std.fmt.bufPrint(&missing_buf, "{s}.missing", .{want}) catch + return error.SkipZigTest; + var out_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectError(error.FileNotFound, Dir.cwd().realPathFile( + test_io, + missing, + &out_buf, + )); + + // libc realpath requires a PATH_MAX-sized output buffer; smaller + // buffers must error rather than risk truncation. + var small_buf: [8]u8 = undefined; + try testing.expectError(error.NameTooLong, Dir.cwd().realPathFile( + test_io, + want, + &small_buf, + )); +} + +test "deleteFile edge cases" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Nonexistent files. + try testing.expectError(error.FileNotFound, dir.deleteFile(test_io, "missing.txt")); + + // NUL bytes never form a valid POSIX path. + try testing.expectError(error.BadPathName, dir.deleteFile(test_io, "bad\x00path")); + + // Deleting a directory reports IsDir. On BSD-derived systems + // (including macOS) unlink returns EPERM for directories, which + // exercises the stat-based disambiguation path. + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + try testing.expectError(error.IsDir, dir.deleteFile(test_io, "subdir")); + + // Deleting a symlink removes the link, not its target. + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "target.txt", + .data = "x", + }); + try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); + try dir.deleteFile(test_io, "link.txt"); + var file = try dir.openFile(test_io, "target.txt", .{}); + file.close(test_io); + try testing.expectError(error.FileNotFound, dir.openFile(test_io, "link.txt", .{})); +} + +test "dirClose closes the descriptor" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); + const opened = try tmp_dir.dir.openDir(testing.io, "subdir", .{}); + + const dir: Dir = .{ .handle = opened.handle }; + test_io.vtable.dirClose(test_io.userdata, &.{dir}); + + // Verify with a raw fstat that the fd is gone. We check the errno + // directly rather than going through our Io so no error path prints + // "unexpected errno" diagnostics in debug test builds. + var st = std.mem.zeroes(posix.Stat); + try testing.expectEqual(posix.E.BADF, posix.errno(fstat_sym(dir.handle, &st))); +} + +test "cancel protection operations are benign" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // There is no async, so cancelation can never be requested. + try test_io.vtable.checkCancel(test_io.userdata); + + // Swap and restore roundtrip like generic std code does around + // uninterruptible sections. + const prev = test_io.vtable.swapCancelProtection(test_io.userdata, .blocked); + _ = test_io.vtable.swapCancelProtection(test_io.userdata, prev); + test_io.vtable.recancel(test_io.userdata); + + _ = testing; +} + +test "async runs inline; concurrency is unavailable" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + const S = struct { + fn work(x: *u32) u32 { + x.* += 1; + return x.*; + } + }; + + // The task must have executed synchronously, before await. + var state: u32 = 41; + var future = test_io.async(S.work, .{&state}); + try testing.expectEqual(@as(u32, 42), state); + try testing.expectEqual(@as(u32, 42), future.await(test_io)); + + // Concurrency must be reported as unavailable, not silently run. + try testing.expectError( + error.ConcurrencyUnavailable, + test_io.concurrent(S.work, .{&state}), + ); + try testing.expectEqual(@as(u32, 42), state); +} + +test "futex timed waits return" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + + // Nobody wakes this futex, so returning at all proves the timeout + // (or the spurious-wakeup contract) works. + var word: u32 = 1; + try test_io.vtable.futexWait(test_io.userdata, &word, 1, .{ + .duration = .{ .raw = .fromNanoseconds(5 * std.time.ns_per_ms), .clock = .awake }, + }); + + // Deadlines can't be resolved without a clock; they degrade to a + // short poll which must also return. + try test_io.vtable.futexWait(test_io.userdata, &word, 1, .{ + .deadline = .{ .raw = .fromNanoseconds(1), .clock = .awake }, + }); + + // A mismatched expected value returns immediately even with no + // timeout. + try test_io.vtable.futexWait(test_io.userdata, &word, 2, .none); + + // Waking more than one waiter takes the wake-all path. + test_io.vtable.futexWake(test_io.userdata, &word, 2); + test_io.vtable.futexWake(test_io.userdata, &word, std.math.maxInt(u32)); +} + +test "Io.Mutex under real thread contention" { + if (comptime !supported) return error.SkipZigTest; + if (comptime builtin.single_threaded) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + // Hammer a mutex from several threads so waiters actually park in + // futexWait and get released by futexWake. + const S = struct { + const iterations = 10_000; + + fn worker(m: *Io.Mutex, io_: Io, counter: *u64) void { + for (0..iterations) |_| { + m.lockUncancelable(io_); + defer m.unlock(io_); + counter.* += 1; + } + } + }; + + var mutex: Io.Mutex = .init; + var counter: u64 = 0; + + const thread_count = 4; + var threads: [thread_count]std.Thread = undefined; + var spawned: usize = 0; + defer for (threads[0..spawned]) |t| t.join(); + for (&threads) |*t| { + t.* = std.Thread.spawn(.{}, S.worker, .{ &mutex, test_io, &counter }) catch + break; + spawned += 1; + } + for (threads[0..spawned]) |t| t.join(); + spawned = 0; + + try testing.expectEqual(@as(u64, S.iterations * thread_count), counter); +} diff --git a/src/lib/main.zig b/src/lib/main.zig index d9c35927b..45cdb71d4 100644 --- a/src/lib/main.zig +++ b/src/lib/main.zig @@ -5,6 +5,7 @@ const types = @import("types.zig"); const unionpkg = @import("union.zig"); pub const allocator = @import("allocator.zig"); +pub const TinyIo = @import("TinyIo.zig"); pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 68f275b26..ffd56939f 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -38,6 +38,17 @@ const terminal = @import("terminal/main.zig"); /// Additional functionality will be added here over time as needed. pub const sys = terminal.sys; +/// A tiny, blocking `std.Io` implementation optimized for binary size. +/// +/// Constructing a `Terminal` requires a `std.Io` for features that touch +/// the filesystem (e.g. Kitty graphics file transmission). Embedders that +/// don't have their own `Io` can use `TinyIo` (e.g. +/// `(TinyIo.init).io()`) instead of `std.Io.Threaded` to avoid linking +/// Threaded's full vtable (networking, process spawning, async +/// machinery, etc.), which is worth roughly 110KB of binary size. See +/// the TinyIo docs for the exact tradeoffs. +pub const TinyIo = @import("lib/TinyIo.zig"); + pub const apc = terminal.apc; pub const dcs = terminal.dcs; pub const osc = terminal.osc; diff --git a/src/terminal/c/snapshot.zig b/src/terminal/c/snapshot.zig index 38cce89b1..45ac8a1e3 100644 --- a/src/terminal/c/snapshot.zig +++ b/src/terminal/c/snapshot.zig @@ -719,33 +719,6 @@ test "decoder option and empty source" { try testing.expectEqual(null, terminal); } -test "snapshot decoder defers terminal I/O allocation until READY" { - var failing = testing.FailingAllocator.init(testing.allocator, .{ - // The decoder wrapper is the only allocation performed by new_buf. - // Fail the following allocation, which creates terminal-owned I/O. - .fail_index = 1, - }); - const failing_zig = failing.allocator(); - const failing_c: CAllocator = .fromZig(&failing_zig); - - var decoder: Decoder = null; - try testing.expectEqual(Result.success, decoder_new_buf( - &failing_c, - &decoder, - null, - 0, - )); - defer decoder_free(decoder); - - var terminal: terminal_c.Terminal = null; - try testing.expectEqual(Result.out_of_memory, decoder_ready( - decoder, - &terminal, - )); - try testing.expectEqual(null, terminal); - try testing.expectEqual(@as(usize, 0), decoder.?.source.offset()); -} - test "snapshot C API full round trip restores continuation" { var source: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 99a45e1ed..92336ef53 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -51,17 +51,25 @@ pub const Io = struct { impl: Impl, /// Platform-specific storage backing the public `std.Io` value. - const Impl = if (builtin.os.tag != .freestanding) + /// + /// Where supported (POSIX) we use TinyIo, which is stateless and + /// supports exactly the operations the terminal needs at a fraction + /// of the code size (see lib/TinyIo.zig). On Windows we use + /// `std.Io.Threaded` since TinyIo doesn't implement the NT + /// operations. On the remaining targets (e.g. freestanding wasm) + /// TinyIo degrades to `std.Io.failing`, which is correct: they have + /// no filesystem. + const Impl = if (builtin.os.tag == .windows) *std.Io.Threaded else - void; + lib.TinyIo; /// Allocation failures possible while constructing an I/O owner. pub const Error = error{OutOfMemory}; /// Allocate the native I/O implementation when the platform requires it. pub fn init(alloc: std.mem.Allocator) Error!Io { - if (comptime builtin.os.tag == .freestanding) return .{ .impl = {} }; + if (comptime Impl == lib.TinyIo) return .{ .impl = .init }; const ptr = alloc.create(std.Io.Threaded) catch return error.OutOfMemory; @@ -71,15 +79,15 @@ pub const Io = struct { /// Return the value passed to native terminal construction and decoding. pub fn io(self: Io) std.Io { - if (comptime builtin.os.tag == .freestanding) { - return std.Io.failing; - } return self.impl.io(); } /// Release an I/O implementation that has not already been transferred. pub fn deinit(self: Io, alloc: std.mem.Allocator) void { - if (comptime builtin.os.tag != .freestanding) { + // Note: this must not name `std.Io.Threaded` in the condition + // because resolving that type trips its container-level comptime + // checks on targets it doesn't support (e.g. wasm32-freestanding). + if (comptime Impl != lib.TinyIo) { self.impl.deinit(); alloc.destroy(self.impl); } diff --git a/src/terminal/lib.zig b/src/terminal/lib.zig index 921d831b7..040d1c4cd 100644 --- a/src/terminal/lib.zig +++ b/src/terminal/lib.zig @@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c; /// Forwarded decls from lib that are used. pub const alloc = lib.allocator; +pub const TinyIo = lib.TinyIo; pub const Buffer = lib.Buffer; pub const Enum = lib.Enum; pub const TaggedUnion = lib.TaggedUnion; diff --git a/typos.toml b/typos.toml index 2deb781be..46ac28dd8 100644 --- a/typos.toml +++ b/typos.toml @@ -80,6 +80,9 @@ rin = "rin" ower = "ower" # OpenType table names loca = "loca" +# TinyIo +tio = "tio" +WRONLY = "WRONLY" [type.po] extend-glob = ["*.po"] From d524a0fe645cfd4d9ae27bd8451b934dcf2ebaef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 13:43:59 -0700 Subject: [PATCH 5/6] libghostty: guard release builds against std debug Io Release libghostty-vt builds carefully avoid referencing std.Options.debug_io because its default implementation is std.Io.Threaded, and referencing that vtable keeps every operation Threaded supports linked into the binary: roughly 110KB of unreachable code. Nothing references it today, but any std.debug.print, std.debug.lockStderr, or std.log default-handler call added to release-reachable code would silently reintroduce all of it. Declare std_options_debug_io in the root module so std uses our value instead of constructing the Threaded default. Development builds (Debug, ReleaseSafe, tests) forward the std default so std.debug.print and friends work normally. ReleaseFast and ReleaseSmall builds declare it as a @compileError: since std only analyzes the declaration lazily, at the moment something references a debug Io code path, the error fires exactly at the offending reference, turning a silent size regression into a build failure with a message explaining the alternatives. Release binaries are byte-identical when the guard is not tripped. --- src/lib_vt.zig | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ffd56939f..5b78b8cbe 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -426,19 +426,45 @@ pub const std_options: std.Options = opts: { break :opts options; }; +/// True for builds where we keep the full std debug machinery (stack +/// traces on panic, std.debug.print, etc.). These builds are for +/// development, where the roughly 160KB of binary size it costs is +/// worth it. +const debug_machinery: bool = builtin.is_test or switch (builtin.mode) { + .Debug, .ReleaseSafe => true, + .ReleaseFast, .ReleaseSmall => false, +}; + /// The panic handler for when this file is the root module. /// /// In ReleaseFast and ReleaseSmall builds we print the panic message to /// stderr and trap, but do not attempt to unwind the stack to print a /// stack trace. -pub const panic: type = if (builtin.is_test or switch (builtin.mode) { - .Debug, .ReleaseSafe => true, - .ReleaseFast, .ReleaseSmall => false, -}) +pub const panic: type = if (debug_machinery) std.debug.FullPanic(std.debug.defaultPanic) else std.debug.FullPanic(tinyPanicImpl); +/// Guards release builds against accidentally reintroducing the std +/// debug Io machinery. +/// +/// `std.Options.debug_io` defaults to `std.Io.Threaded`, and anything +/// that reaches it (std.debug.print, std.debug.lockStderr, the default +/// std.log handler, etc.) pins Threaded's entire vtable into the binary: +/// roughly 110KB of unreachable code. +/// +/// This verifies nothing ever touches it. +pub const std_options_debug_io: std.Io = if (debug_machinery) + std.Io.Threaded.global_single_threaded.io() +else + @compileError( + \\The std debug Io machinery (std.debug.print, std.debug.lockStderr, + \\std.log's default handler, ...) is disabled in libghostty-vt release + \\builds because it costs ~110KB of binary size. Use std.log (routed + \\through our logFn), os/stderr.zig for raw diagnostic writes, or + \\gate the code on debug builds. + ); + /// Prints the panic message to stderr (best-effort) and traps. /// /// This intentionally avoids `std.debug.lockStderr`, which routes through From a15dddae2a497e6540feca5c6adc7e86edd59adb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 14:05:18 -0700 Subject: [PATCH 6/6] TinyIo: fix failing tests in CI --- src/lib/TinyIo.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig index b81648d1a..f89278070 100644 --- a/src/lib/TinyIo.zig +++ b/src/lib/TinyIo.zig @@ -1402,11 +1402,12 @@ test "dirClose closes the descriptor" { const dir: Dir = .{ .handle = opened.handle }; test_io.vtable.dirClose(test_io.userdata, &.{dir}); - // Verify with a raw fstat that the fd is gone. We check the errno + // Verify with a raw dup that the fd is gone. We check the errno // directly rather than going through our Io so no error path prints - // "unexpected errno" diagnostics in debug test builds. - var st = std.mem.zeroes(posix.Stat); - try testing.expectEqual(posix.E.BADF, posix.errno(fstat_sym(dir.handle, &st))); + // "unexpected errno" diagnostics in debug test builds. `dup` rather + // than `fstat` because glibc has no LFS64 `fstat` symbol, so + // `fstat_sym` doesn't exist on Linux. + try testing.expectEqual(posix.E.BADF, posix.errno(posix.system.dup(dir.handle))); } test "cancel protection operations are benign" {