diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/lib | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/lib')
21 files changed, 3394 insertions, 167 deletions
diff --git a/vendor/libghostty-vt/src/lib/TinyIo.zig b/vendor/libghostty-vt/src/lib/TinyIo.zig new file mode 100644 index 00000000..118af197 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/TinyIo.zig @@ -0,0 +1,269 @@ +//! 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. On Windows the +//! DLL is ~370KB smaller (ReleaseFast) because Threaded's Windows logic +//! has Winsock, AFD and process creation. +//! +//! 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). +//! +//! The platform arms live in `tinyio/`: `posix.zig` is direct syscalls +//! cribbed from Zig's std.posix, and `windows.zig` is direct ntdll and +//! kernel32 calls modeled on the Windows arms of `std.Io.Threaded`, so +//! the library never links Threaded's vtable there either. Shared tests +//! are in `tinyio/test.zig`. + +const TinyIo = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +/// 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) { + .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 = impl.dirOpenFile, + .dirClose = impl.dirClose, + .dirRead = Io.noDirRead, + .dirRealPath = Io.failingDirRealPath, + .dirRealPathFile = impl.dirRealPathFile, + .dirDeleteFile = impl.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 = impl.fileStat, + .fileLength = impl.fileLength, + .fileClose = impl.fileClose, + .fileWritePositional = Io.failingFileWritePositional, + .fileWriteFileStreaming = Io.noFileWriteFileStreaming, + .fileWriteFilePositional = Io.noFileWriteFilePositional, + .fileReadPositional = impl.fileReadPositional, + .fileSeekBy = impl.fileSeekBy, + .fileSeekTo = impl.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 = impl.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 = randomSecure, + + .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, +}; + +/// The platform arm behind the vtable. Each arm exports the same set of +/// operations; only the selected one is ever analyzed, and `supported` +/// gates the vtable so unsupported targets never resolve either. +const impl = switch (builtin.os.tag) { + .windows => @import("tinyio/windows.zig"), + else => @import("tinyio/posix.zig"), +}; + +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 randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void { + if (buffer.len == 0) return; + return impl.randomSecure(buffer); +} + +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 = impl.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), + } +} + +/// 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; + impl.futexWaitInner(ptr, expected, timeoutToNs(timeout)); +} + +fn futexWaitUncancelable(userdata: ?*anyopaque, ptr: *const u32, expected: u32) void { + _ = userdata; + impl.futexWaitInner(ptr, expected, null); +} + +fn futexWake(userdata: ?*anyopaque, ptr: *const u32, max_waiters: u32) void { + return impl.futexWake(userdata, ptr, max_waiters); +} + +test { + _ = @import("tinyio/test.zig"); +} diff --git a/vendor/libghostty-vt/src/lib/allocator.zig b/vendor/libghostty-vt/src/lib/allocator.zig index fc56033a..de343dab 100644 --- a/vendor/libghostty-vt/src/lib/allocator.zig +++ b/vendor/libghostty-vt/src/lib/allocator.zig @@ -2,8 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; -/// Convenience functions -pub const convenience = @import("allocator/convenience.zig"); +/// Wasm-specific allocation helpers. +pub const wasm = @import("allocator/wasm.zig"); /// Useful alias since they're required to create Zig allocators pub const ZigVTable = std.mem.Allocator.VTable; @@ -34,6 +34,10 @@ pub fn default(c_alloc_: ?*const Allocator) std.mem.Allocator { // Wasm if (comptime builtin.target.cpu.arch.isWasm()) return std.heap.wasm_allocator; + // Freestanding targets don't have a default heap. Using the failing + // allocator makes a missing allocator show up as out-of-memory. + if (comptime builtin.os.tag == .freestanding) return std.mem.Allocator.failing; + // No libc, use the preferred allocator for releases which is the // Zig SMP allocator. return std.heap.smp_allocator; diff --git a/vendor/libghostty-vt/src/lib/allocator/convenience.zig b/vendor/libghostty-vt/src/lib/allocator/convenience.zig deleted file mode 100644 index 0f5f88d2..00000000 --- a/vendor/libghostty-vt/src/lib/allocator/convenience.zig +++ /dev/null @@ -1,59 +0,0 @@ -//! This contains convenience functions for allocating various types. -//! -//! The primary use case for this is Wasm builds. Ghostty relies a lot on -//! pointers to various types for ABI compatibility and creating those pointers -//! in Wasm is tedious. This file contains a purely additive set of functions -//! that can be exposed to the Wasm module without changing the API from the -//! C library. -//! -//! Given these are convenience methods, they always use the default allocator. -//! If a caller is using a custom allocator, they have the expertise to -//! allocate these types manually using their custom allocator. - -// Get our default allocator at comptime since it is known. -const default = @import("../allocator.zig").default; -const alloc = default(null); - -pub const Opaque = *anyopaque; - -pub fn allocOpaque() callconv(.c) ?*Opaque { - return alloc.create(*anyopaque) catch return null; -} - -pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} - -pub fn allocU8Array(len: usize) callconv(.c) ?[*]u8 { - const slice = alloc.alloc(u8, len) catch return null; - return slice.ptr; -} - -pub fn freeU8Array(ptr: ?[*]u8, len: usize) callconv(.c) void { - if (ptr) |p| alloc.free(p[0..len]); -} - -pub fn allocU16Array(len: usize) callconv(.c) ?[*]u16 { - const slice = alloc.alloc(u16, len) catch return null; - return slice.ptr; -} - -pub fn freeU16Array(ptr: ?[*]u16, len: usize) callconv(.c) void { - if (ptr) |p| alloc.free(p[0..len]); -} - -pub fn allocU8() callconv(.c) ?*u8 { - return alloc.create(u8) catch return null; -} - -pub fn freeU8(ptr: ?*u8) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} - -pub fn allocUsize() callconv(.c) ?*usize { - return alloc.create(usize) catch return null; -} - -pub fn freeUsize(ptr: ?*usize) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} diff --git a/vendor/libghostty-vt/src/lib/allocator/wasm.zig b/vendor/libghostty-vt/src/lib/allocator/wasm.zig new file mode 100644 index 00000000..22439103 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/allocator/wasm.zig @@ -0,0 +1,98 @@ +//! Wasm allocation conveniences for caller-owned storage and opaque out slots. +//! +//! The primary use case for this is Wasm builds. Ghostty relies a lot on +//! pointers to various types for ABI compatibility and creating those pointers +//! in Wasm is tedious. This file contains the small set of functions exposed by +//! the Wasm module without changing the API from the C library. +//! +//! Given these are convenience methods, they always use the default allocator. +//! If a caller is using a custom allocator, they have the expertise to +//! allocate these types manually using their custom allocator. + +const std = @import("std"); +const c_abi = @import("../c_abi.zig"); + +// Get our default allocator at comptime since it is known. +const default = @import("../allocator.zig").default; +const alloc = default(null); +const wasm_alignment: std.mem.Alignment = .fromByteUnits(c_abi.max_alignment); + +/// A nullable opaque C handle stored in a constructor out-parameter slot. +pub const Opaque = ?*anyopaque; + +/// Allocate `len` bytes of uninitialized, caller-owned Wasm ABI storage. +/// +/// The returned pointer is aligned for any fundamental C ABI type and must be +/// released with `freeBytes` using the same `len`. Returns null when `len` is +/// zero or allocation fails. +pub fn allocBytes(len: usize) callconv(.c) ?[*]u8 { + if (len == 0) return null; + return alloc.rawAlloc(len, wasm_alignment, @returnAddress()); +} + +/// Release storage returned by `allocBytes`. +/// +/// `len` must exactly match the allocation length. A null pointer is ignored. +pub fn freeBytes(ptr: ?[*]u8, len: usize) callconv(.c) void { + const p = ptr orelse return; + if (len == 0) return; + alloc.rawFree(p[0..len], wasm_alignment, @returnAddress()); +} + +/// Allocate a null-initialized slot for an opaque constructor out-parameter. +/// +/// The slot may be reused after each value is removed with `takeOpaque`. It +/// must eventually be released with `freeOpaque`. +pub fn allocOpaque() callconv(.c) ?*Opaque { + const ptr = allocBytes(@sizeOf(Opaque)) orelse return null; + const result: *Opaque = @ptrCast(@alignCast(ptr)); + result.* = null; + return result; +} + +/// Release a slot returned by `allocOpaque` without freeing its stored handle. +/// +/// Call the handle's type-specific destructor before freeing a populated slot. +/// A null slot pointer is ignored. +pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { + freeBytes(@ptrCast(ptr), @sizeOf(Opaque)); +} + +/// Remove and return the handle in an opaque out-parameter slot. +/// +/// The slot is reset to null so it can be safely reused for another +/// constructor. This does not free either the handle or the slot. Returns null +/// when the slot pointer is null or the slot is empty. +pub fn takeOpaque(ptr: ?*Opaque) callconv(.c) Opaque { + const p = ptr orelse return null; + const result = p.*; + p.* = null; + return result; +} + +test "Wasm allocation" { + const ptr = allocBytes(1) orelse return error.OutOfMemory; + defer freeBytes(ptr, 1); + + try std.testing.expectEqual( + @as(usize, 0), + @intFromPtr(ptr) % c_abi.max_alignment, + ); + ptr[0] = 42; + try std.testing.expectEqual(@as(u8, 42), ptr[0]); + + try std.testing.expect(allocBytes(0) == null); + freeBytes(null, 0); +} + +test "opaque slots are initialized and reusable" { + const slot = allocOpaque() orelse return error.OutOfMemory; + defer freeOpaque(slot); + + try std.testing.expect(takeOpaque(slot) == null); + + var value: u8 = 42; + slot.* = &value; + try std.testing.expectEqual(@as(?*anyopaque, &value), takeOpaque(slot)); + try std.testing.expect(takeOpaque(slot) == null); +} diff --git a/vendor/libghostty-vt/src/lib/c_abi.zig b/vendor/libghostty-vt/src/lib/c_abi.zig new file mode 100644 index 00000000..3ce34754 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/c_abi.zig @@ -0,0 +1,28 @@ +//! Memory-layout properties shared with C API callers. +//! +//! The C ABI defines how values are represented in memory so code written in +//! different languages can safely exchange them. One part of that contract is +//! alignment: some values must begin at an address divisible by 2, 4, 8, or +//! another power of two. Reading a value from a less-aligned address can be +//! slow on some CPUs and invalid on others. +//! +//! This module derives those properties from Zig's compile-time target data. +//! It does not call or link libc. Keeping the calculation here gives allocators +//! and ABI metadata one source of truth. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Largest address alignment required by a fundamental C ABI type, in bytes. +/// +/// For example, a value of 16 means storage intended to hold an arbitrary C +/// value must begin at an address evenly divisible by 16. The Wasm allocator +/// uses this value for caller-owned ABI storage, and `ghostty_type_json` +/// publishes it so hosts know the guarantee made by that allocator. +pub const max_alignment: u16 = max: { + var result: u16 = @alignOf(*anyopaque); + for (std.enums.values(std.Target.CType)) |c_type| { + result = @max(result, builtin.target.cTypeAlignment(c_type)); + } + break :max result; +}; diff --git a/vendor/libghostty-vt/src/lib/compat/README.md b/vendor/libghostty-vt/src/lib/compat/README.md new file mode 100644 index 00000000..22f58c10 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/README.md @@ -0,0 +1,37 @@ +# Zig Compatibility Libraries + +The following directory contains code that either used to exist in the Zig +standard library, or currently exists in Zig master that has been deemed +useful to have ahead of a full release. + +Code located here should likely be eventually phased out, or moved to a more +logical location if it's decided it would be useful to keep. + +## Licenses + +Zig code taken from the standard library is used under the terms of the MIT +license. The license body follows. + +``` +The MIT License (Expat) + +Copyright (c) Zig contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` diff --git a/vendor/libghostty-vt/src/lib/compat/fd.zig b/vendor/libghostty-vt/src/lib/compat/fd.zig new file mode 100644 index 00000000..3bef7414 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/fd.zig @@ -0,0 +1,83 @@ +//! Code taken from 0.15.2 and `std.fs.file`. See README.md for license and +//! details. +const std = @import("std"); + +pub const PipeError = error{ + SystemFdQuotaExceeded, + ProcessFdQuotaExceeded, +} || std.posix.UnexpectedError; + +pub fn close(fd: std.posix.fd_t) void { + switch (std.posix.errno(std.posix.system.close(fd))) { + .BADF => unreachable, // Always a race condition + else => {}, // Includes EINTR, see std.posix source in 0.15.2 + } +} + +pub fn pipe() PipeError![2]std.posix.fd_t { + var fds: [2]std.posix.fd_t = undefined; + switch (std.posix.errno(std.posix.system.pipe(&fds))) { + .SUCCESS => return fds, + .INVAL => unreachable, // Invalid parameters to pipe() + .FAULT => unreachable, // Invalid fds pointer + .NFILE => return error.SystemFdQuotaExceeded, + .MFILE => return error.ProcessFdQuotaExceeded, + else => |err| return std.posix.unexpectedErrno(err), + } +} + +pub fn pipe2(flags: std.posix.O) PipeError![2]std.posix.fd_t { + if (@TypeOf(std.posix.system.pipe2) != void) { + var fds: [2]std.posix.fd_t = undefined; + switch (std.posix.errno(std.posix.system.pipe2(&fds, flags))) { + .SUCCESS => return fds, + .INVAL => unreachable, // Invalid flags + .FAULT => unreachable, // Invalid fds pointer + .NFILE => return error.SystemFdQuotaExceeded, + .MFILE => return error.ProcessFdQuotaExceeded, + else => |err| return std.posix.unexpectedErrno(err), + } + } + + const fds: [2]std.posix.fd_t = try pipe(); + errdefer { + close(fds[0]); + close(fds[1]); + } + + // https://github.com/ziglang/zig/issues/18882 + if (@as(u32, @bitCast(flags)) == 0) + return fds; + + // CLOEXEC is special, it's a file descriptor flag and must be set using + // F.SETFD. + if (flags.CLOEXEC) { + for (fds) |fd| { + switch (std.posix.errno(std.posix.system.fcntl(fd, std.posix.F.SETFD, @as(u32, std.posix.FD_CLOEXEC)))) { + .SUCCESS => {}, + .INVAL => unreachable, // Invalid flags + .BADF => unreachable, // Always a race condition + else => |err| return std.posix.unexpectedErrno(err), + } + } + } + + const new_flags: u32 = f: { + var new_flags = flags; + new_flags.CLOEXEC = false; + break :f @bitCast(new_flags); + }; + // Set every other flag affecting the file status using F.SETFL. + if (new_flags != 0) { + for (fds) |fd| { + switch (std.posix.errno(std.posix.system.fcntl(fd, std.posix.F.SETFL, new_flags))) { + .SUCCESS => {}, + .INVAL => unreachable, // Invalid flags + .BADF => unreachable, // Always a race condition + else => |err| return std.posix.unexpectedErrno(err), + } + } + } + + return fds; +} diff --git a/vendor/libghostty-vt/src/lib/compat/file.zig b/vendor/libghostty-vt/src/lib/compat/file.zig new file mode 100644 index 00000000..efb31a52 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/file.zig @@ -0,0 +1,56 @@ +//! Code taken from 0.15.2 and `std.fs.file`. See README.md for license and +//! details. +const builtin = @import("builtin"); +const std = @import("std"); +const global = @import("../../global.zig"); + +pub const ReadToEndAllocError = error{FileTooBig} || + std.Io.File.ReadStreamingError || + std.mem.Allocator.Error; + +/// Read the file from its current position through end-of-stream, returning +/// `error.FileTooBig` if the result exceeds `max_bytes`. +/// +/// Caller owns the memory. +pub fn readToEndAlloc(file: std.Io.File, alloc: std.mem.Allocator, max_bytes: usize) ReadToEndAllocError![]u8 { + var read_buf: [4096]u8 = undefined; + var result: std.ArrayList(u8) = .empty; + defer result.deinit(alloc); + + while (true) { + const n = file.readStreaming(global.io(), &.{&read_buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => |e| return e, + }; + if (n == 0) continue; + if (n > max_bytes - result.items.len) return error.FileTooBig; + try result.appendSlice(alloc, read_buf[0..n]); + } + + return result.toOwnedSlice(alloc); +} + +test "readToEndAlloc reads through EOF and permits exact limit" { + const testing = std.testing; + const contents = "hello, world"; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "data", + .data = contents, + }); + + const file = try tmp_dir.dir.openFile(testing.io, "data", .{}); + defer file.close(testing.io); + const result = try readToEndAlloc(file, testing.allocator, contents.len); + defer testing.allocator.free(result); + try testing.expectEqualStrings(contents, result); + + const too_big_file = try tmp_dir.dir.openFile(testing.io, "data", .{}); + defer too_big_file.close(testing.io); + try testing.expectError( + error.FileTooBig, + readToEndAlloc(too_big_file, testing.allocator, contents.len - 1), + ); +} diff --git a/vendor/libghostty-vt/src/lib/compat/reader.zig b/vendor/libghostty-vt/src/lib/compat/reader.zig new file mode 100644 index 00000000..28813440 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/reader.zig @@ -0,0 +1,64 @@ +//! Code taken from older reader implementations, still may be useful to keep +//! around in general. See README.md for license and details. +const std = @import("std"); + +/// Pulled from old stdlib as this function has been removed. +pub fn readSkipUntilDelimiterOrEof(reader: *std.Io.Reader, delimiter: u8) std.Io.Reader.Error!void { + while (true) { + const byte = readByte(reader) catch |err| switch (err) { + error.EndOfStream => return, + else => |e| return e, + }; + if (byte == delimiter) return; + } +} + +/// Pulled from old stdlib as this function has been removed. +pub fn readStructEndian( + reader: *std.Io.Reader, + comptime T: type, + endian: std.builtin.Endian, +) std.Io.Reader.Error!T { + var result: [1]T = undefined; + try reader.readSliceEndian(T, &result, endian); + return result[0]; +} + +/// Pulled from old stdlib as this function has been removed. +pub fn readerInt(reader: *std.Io.Reader, comptime T: type, endian: std.builtin.Endian) std.Io.Reader.Error!T { + const bytes = try readBytesNoEof(reader, @divExact(@typeInfo(T).int.bits, 8)); + return std.mem.readInt(T, &bytes, endian); +} + +/// Pulled from old stdlib as this function has been removed. +pub fn readByteSigned(reader: *std.Io.Reader) std.Io.Reader.Error!i8 { + return @as(i8, @bitCast(try readByte(reader))); +} + +/// Pulled from old stdlib as this function has been removed. +pub fn readByte(reader: *std.Io.Reader) std.Io.Reader.Error!u8 { + var result: [1]u8 = undefined; + try reader.readSliceAll(&result); + return result[0]; +} + +/// Pulled from old stdlib as this function has been removed. +pub fn readSkipBytes(reader: *std.Io.Reader, num_bytes: u64) std.Io.Reader.Error!void { + const buf_size = 512; + + var buf: [buf_size]u8 = undefined; + var remaining = num_bytes; + + while (remaining > 0) { + const amt = @min(remaining, buf_size); + try reader.readSliceAll(buf[0..amt]); + remaining -= amt; + } +} + +/// Pulled from old stdlib as this function has been removed. +fn readBytesNoEof(reader: *std.Io.Reader, comptime num_bytes: usize) std.Io.Reader.Error![num_bytes]u8 { + var bytes: [num_bytes]u8 = undefined; + try reader.readSliceAll(&bytes); + return bytes; +} diff --git a/vendor/libghostty-vt/src/lib/compat/testing.zig b/vendor/libghostty-vt/src/lib/compat/testing.zig new file mode 100644 index 00000000..e826eb40 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/testing.zig @@ -0,0 +1,19 @@ +//! Code taken from 0.15.2 `std.testing`. See README.md for license and +//! details. +const builtin = @import("builtin"); +const std = @import("std"); + +/// Given a type, recursively references all the declarations inside, so that the semantic analyzer sees them. +/// For deep types, you may use `@setEvalBranchQuota`. +pub fn refAllDeclsRecursive(comptime T: type) void { + if (!builtin.is_test) return; + inline for (comptime std.meta.declarations(T)) |decl| { + if (@TypeOf(@field(T, decl.name)) == type) { + switch (@typeInfo(@field(T, decl.name))) { + .@"struct", .@"enum", .@"union", .@"opaque" => refAllDeclsRecursive(@field(T, decl.name)), + else => {}, + } + } + _ = &@field(T, decl.name); + } +} diff --git a/vendor/libghostty-vt/src/lib/compat/thread.zig b/vendor/libghostty-vt/src/lib/compat/thread.zig new file mode 100644 index 00000000..890c02f0 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/compat/thread.zig @@ -0,0 +1,75 @@ +//! Code taken from 0.17.0 `std.Io.Condition`. See README.md for license and +//! details. +//! +//! FIXME: Should be able to remove after 0.17.0, see: +//! https://codeberg.org/ziglang/zig/pulls/31278 +const builtin = @import("builtin"); +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; + +pub const WaitTimeoutError = std.Io.Cancelable || std.Io.Timeout.Error; + +/// Blocks until the condition is signaled, canceled, or the provided +/// timeout expires. +/// +/// See also: +/// * `wait` +/// * `waitUncancelable` +pub fn waitTimeout( + cond: *std.Io.Condition, + io: std.Io, + mutex: *std.Io.Mutex, + timeout: std.Io.Timeout, +) WaitTimeoutError!void { + const deadline = timeout.toDeadline(io); + + var epoch = cond.epoch.load(.acquire); // `.acquire` to ensure ordered before state load + + { + const prev_state = cond.state.fetchAdd(.{ .waiters = 1, .signals = 0 }, .monotonic); + assert(prev_state.waiters < std.math.maxInt(u16)); // overflow caused by too many waiters + } + + mutex.unlock(io); + defer mutex.lockUncancelable(io); + + while (true) { + const result = io.futexWaitTimeout(u32, &cond.epoch.raw, epoch, deadline); + + epoch = cond.epoch.load(.acquire); // `.acquire` to ensure ordered before `state` load + + // Even on error, try to consume a pending signal first. Otherwise a race might + // cause a signal to get stuck in the state with no corresponding waiter. + { + var prev_state = cond.state.load(.monotonic); + while (prev_state.signals > 0) { + prev_state = cond.state.cmpxchgWeak(prev_state, .{ + .waiters = prev_state.waiters - 1, + .signals = prev_state.signals - 1, + }, .acquire, .monotonic) orelse { + // We successfully consumed a signal. + return; + }; + } + } + + // There are no more signals available; this was a spurious wakeup or an error. If it + // was an error, we will remove ourselves as a waiter and return that error. If a + // timeout was specified and the deadline has passed, we remove ourselves as a waiter + // and return `error.Timeout`. Otherwise, we'll loop back to the futex wait. + result catch |err| { + const prev_state = cond.state.fetchSub(.{ .waiters = 1, .signals = 0 }, .monotonic); + assert(prev_state.waiters > 0); // underflow caused by illegal state + return err; + }; + switch (deadline) { + .none => {}, + .deadline => |d| if (d.untilNow(io).raw.nanoseconds >= 0) { + const prev_state = cond.state.fetchSub(.{ .waiters = 1, .signals = 0 }, .monotonic); + assert(prev_state.waiters > 0); // underflow caused by illegal state + return error.Timeout; + }, + .duration => unreachable, + } + } +} diff --git a/vendor/libghostty-vt/src/lib/enum.zig b/vendor/libghostty-vt/src/lib/enum.zig index 76e05fa9..3c7a3a41 100644 --- a/vendor/libghostty-vt/src/lib/enum.zig +++ b/vendor/libghostty-vt/src/lib/enum.zig @@ -5,58 +5,50 @@ const Target = @import("target.zig").Target; /// if we're targeting C, otherwise a Zig enum with smallest possible /// backing type. /// -/// In all cases, the enum keys will be created in the order given. -/// For C ABI, this means that the order MUST NOT be changed in order -/// to preserve ABI compatibility. You can set a key to null to -/// remove it from the Zig enum while keeping the "hole" in the C enum -/// to preserve ABI compatibility. +/// In all cases, each enum value is its index in `keys`. The order MUST NOT +/// be changed when the integer values are part of an ABI or serialized format. +/// A null key removes that field while preserving its integer hole. /// /// C detection is up to the caller, since there are multiple ways /// to do that. We rely on the `target` parameter to determine whether we /// should create a C compatible enum or a Zig enum. /// -/// For the Zig enum, the enum value is not guaranteed to be stable, so -/// it shouldn't be relied for things like serialization. +/// C enums use `c_int`. Zig enums use the smallest unsigned integer that can +/// represent every key index, including holes. pub fn Enum( target: Target, keys: []const ?[:0]const u8, ) type { - var fields: [keys.len]std.builtin.Type.EnumField = undefined; - var fields_i: usize = 0; - var holes: usize = 0; - for (keys) |key_| { - const key: [:0]const u8 = key_ orelse { - switch (target) { - // For Zig we don't track holes because the enum value - // isn't guaranteed to be stable and we want to use the - // smallest possible backing type. - .zig => {}, - - // For C we must track holes to preserve ABI compatibility - // with subsequent values. - .c => holes += 1, + var names_raw: [keys.len][]const u8 = undefined; + var values_raw: [keys.len]comptime_int = undefined; + const names_actual, const values_actual = kv: { + // Remove null fields while preserving their positions as integer holes. + var to: comptime_int = 0; + for (0..keys.len) |from| { + if (keys[from]) |key| { + names_raw[to] = key; + values_raw[to] = from; + to += 1; } - continue; - }; + } - fields[fields_i] = .{ - .name = key, - .value = fields_i + holes, - }; - fields_i += 1; - } + break :kv .{ names_raw[0..to], values_raw[0..to] }; + }; - // Assigned to var so that the type name is nicer in stack traces. - const Result = @Type(.{ .@"enum" = .{ - .tag_type = switch (target) { - .c => c_int, - .zig => std.math.IntFittingRange(0, fields_i - 1), - }, - .fields = fields[0..fields_i], - .decls = &.{}, - .is_exhaustive = true, - } }); - return Result; + const TagInt = switch (target) { + .c => c_int, + .zig => std.math.IntFittingRange(0, keys.len - 1), + }; + + return @Enum(TagInt, .exhaustive, names_actual, &(values: { + // We have to transform our comptime_int values into the actual int + // we're creating the enum as. + var result_int_values: [values_actual.len]TagInt = undefined; + for (0..values_actual.len) |idx| { + result_int_values[idx] = values_actual[idx]; + } + break :values result_int_values; + })); } test "zig" { @@ -73,7 +65,7 @@ test "c" { try testing.expectEqual(c_int, info.tag_type); } -test "abi by removing a key" { +test "stable values when removing a key" { const testing = std.testing; // C { @@ -88,10 +80,28 @@ test "abi by removing a key" { const T = Enum(.zig, &.{ "a", "b", null, "d" }); const info = @typeInfo(T).@"enum"; try testing.expectEqual(u2, info.tag_type); - try testing.expectEqual(2, @intFromEnum(T.d)); + try testing.expectEqual(3, @intFromEnum(T.d)); } } +test "zig backing integer includes trailing holes" { + const testing = std.testing; + const T = Enum(.zig, &.{ "a", null, null, null, null }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(u3, info.tag_type); + try testing.expectEqual(0, @intFromEnum(T.a)); +} + +test "zig values remain stable across multiple holes" { + const testing = std.testing; + const T = Enum(.zig, &.{ null, "b", null, "d", null, "f" }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(u3, info.tag_type); + try testing.expectEqual(1, @intFromEnum(T.b)); + try testing.expectEqual(3, @intFromEnum(T.d)); + try testing.expectEqual(5, @intFromEnum(T.f)); +} + /// Verify that for every key in enum T, there is a matching declaration in /// `ghostty.h` with the correct value. This should only ever be called inside a `test` /// because the `ghostty.h` module is only available then. @@ -125,7 +135,10 @@ pub fn checkGhosttyHEnum( if (@hasDecl(c, expected_name)) { std.testing.expectEqual(field.value, @field(c, expected_name)) catch |e| { - std.log.err(@typeName(T) ++ " key " ++ field.name ++ " does not have the same backing int as " ++ expected_name, .{}); + std.log.err( + "{s} key {s} does not have the same backing int as " ++ expected_name, + .{ @typeName(T), field.name }, + ); return e; }; diff --git a/vendor/libghostty-vt/src/lib/main.zig b/vendor/libghostty-vt/src/lib/main.zig index b22b3d98..573007bd 100644 --- a/vendor/libghostty-vt/src/lib/main.zig +++ b/vendor/libghostty-vt/src/lib/main.zig @@ -1,19 +1,22 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const packedpkg = @import("packed.zig"); const structpkg = @import("struct.zig"); 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 Packed = packedpkg.Packed; +pub const PackedTaggedUnion = packedpkg.PackedTaggedUnion; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; pub const Struct = structpkg.Struct; pub const structSizedFieldFits = structpkg.sizedFieldFits; pub const Target = @import("target.zig").Target; pub const TaggedUnion = unionpkg.TaggedUnion; -pub const cutPrefix = @import("string.zig").cutPrefix; test { std.testing.refAllDecls(@This()); diff --git a/vendor/libghostty-vt/src/lib/packed.zig b/vendor/libghostty-vt/src/lib/packed.zig new file mode 100644 index 00000000..b210242d --- /dev/null +++ b/vendor/libghostty-vt/src/lib/packed.zig @@ -0,0 +1,290 @@ +const std = @import("std"); +const testing = std.testing; + +/// Metadata for a field in a packed layout. +pub const FieldOptions = struct { + /// Public name for this field. The Zig field name is used by default. + name: ?[]const u8 = null, + + /// Public type name for scalar fields. The Zig type is used by default. + type_name: ?[]const u8 = null, + + /// Omit this field from public metadata. This is intended for padding. + omit: bool = false, + + /// How the field is represented in public metadata. + encoding: Encoding = .scalar, + + pub const Encoding = union(enum) { + scalar, + @"packed": type, + tagged_union: type, + }; +}; + +/// Create metadata for a packed struct. Physical layout information is always +/// reflected from `T`; options only provide public names and relationships. +pub fn Packed( + comptime T: type, + comptime options: PackedOptions(T), +) type { + const info = packedStructInfo(T); + const FieldT = std.meta.FieldEnum(T); + + return struct { + pub const Zig = T; + + // I know this is an insane amount of validation but getting + // this correct is critical to C APIs working so we go overboard. + comptime { + for (info.fields, 0..) |field, i| { + const field_options = @field(options.fields, field.name); + if (field_options.omit) { + if (field_options.name != null or + field_options.type_name != null or + field_options.encoding != .scalar) + { + @compileError("omitted packed field has other options: " ++ field.name); + } + continue; + } + + if (field_options.name) |name| { + if (name.len == 0) + @compileError("packed field name cannot be empty: " ++ field.name); + } + + switch (field_options.encoding) { + .scalar => switch (@typeInfo(field.type)) { + .bool, .int, .@"enum" => {}, + else => @compileError("packed field requires an explicit encoding: " ++ field.name), + }, + .@"packed" => |Layout| { + if (Layout.Zig != field.type) + @compileError("nested packed layout has the wrong Zig type: " ++ field.name); + if (field_options.type_name != null) + @compileError("nested packed field cannot have a scalar type name: " ++ field.name); + }, + .tagged_union => |Layout| { + if (Layout.Owner != T or Layout.Union != field.type or + Layout.union_field != @field(FieldT, field.name)) + { + @compileError("tagged union layout does not match packed field: " ++ field.name); + } + if (field_options.type_name != null) + @compileError("tagged union field cannot have a scalar type name: " ++ field.name); + }, + } + + const public_name = field_options.name orelse field.name; + for (info.fields[0..i]) |previous| { + const previous_options = @field(options.fields, previous.name); + if (previous_options.omit) continue; + const previous_name = previous_options.name orelse previous.name; + if (std.mem.eql(u8, public_name, previous_name)) + @compileError("duplicate public packed field name: " ++ public_name); + } + } + } + + pub const Backing = info.backing_integer.?; + pub const Field = std.meta.FieldEnum(T); + + pub fn fieldOptions(comptime field: Field) FieldOptions { + return @field(options.fields, @tagName(field)); + } + + pub fn fieldName(comptime field: Field) ?[]const u8 { + const field_options = fieldOptions(field); + if (field_options.omit) return null; + return field_options.name orelse @tagName(field); + } + + pub fn bitOffset(comptime field: Field) usize { + return @bitOffsetOf(T, @tagName(field)); + } + + pub fn bitWidth(comptime field: Field) usize { + return @bitSizeOf(@FieldType(T, @tagName(field))); + } + }; +} + +/// Options for a packed struct. A field is generated for every field in `T` +/// so unknown field names are rejected by normal Zig type checking. +pub fn PackedOptions(comptime T: type) type { + const fields = packedStructInfo(T).fields; + const default_options: FieldOptions = .{}; + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = FieldOptions; + attrs[i] = .{ .default_value_ptr = &default_options }; + } + + const Fields = @Struct(.auto, null, &names, &types, &attrs); + return struct { + fields: Fields = .{}, + }; +} + +/// Describe a packed union field whose active arm is selected by another +/// field in the containing packed struct. Each tag maps to a reflected packed +/// arm layout. Multiple tags may map to the same source union field. +pub fn PackedTaggedUnion( + comptime OwnerT: type, + comptime union_field_value: std.meta.FieldEnum(OwnerT), + comptime tag_field_value: std.meta.FieldEnum(OwnerT), + comptime options: PackedTaggedUnionOptions( + @FieldType(OwnerT, @tagName(union_field_value)), + @FieldType(OwnerT, @tagName(tag_field_value)), + ), +) type { + _ = packedStructInfo(OwnerT); + const UnionT = @FieldType(OwnerT, @tagName(union_field_value)); + const union_info = @typeInfo(UnionT).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + + const TagT = @FieldType(OwnerT, @tagName(tag_field_value)); + const tag_info = @typeInfo(TagT).@"enum"; + const ArmT = PackedTaggedUnionArm(UnionT); + + return struct { + pub const Owner = OwnerT; + pub const Union = UnionT; + pub const Tag = TagT; + pub const Arm = ArmT; + pub const union_field = union_field_value; + pub const tag_field = tag_field_value; + + comptime { + for (tag_info.fields) |tag| { + const arm_value = @field(options.arms, tag.name) orelse continue; + switch (arm_value) { + inline else => |Layout, source| { + const source_name = @tagName(source); + if (Layout.Zig != @FieldType(UnionT, source_name)) + @compileError("packed union arm layout has the wrong Zig type for tag: " ++ tag.name); + if (@bitSizeOf(Layout.Zig) > @bitSizeOf(UnionT)) + @compileError("packed union arm is wider than its union: " ++ tag.name); + }, + } + } + } + + pub fn arm(comptime tag: Tag) ?Arm { + return @field(options.arms, @tagName(tag)); + } + }; +} + +/// Options for mapping tag values to packed union arms. +pub fn PackedTaggedUnionOptions(comptime Union: type, comptime Tag: type) type { + const union_info = @typeInfo(Union).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const Arm = PackedTaggedUnionArm(Union); + const default_arm: ?Arm = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?Arm; + attrs[i] = .{ .default_value_ptr = &default_arm }; + } + + const Arms = @Struct(.auto, null, &names, &types, &attrs); + return struct { + arms: Arms = .{}, + }; +} + +fn PackedTaggedUnionArm(comptime Union: type) type { + const fields = @typeInfo(Union).@"union".fields; + const Tag = std.meta.FieldEnum(Union); + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.UnionField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = type; + attrs[i] = .{ .@"align" = 1 }; + } + + return @Union(.auto, Tag, &names, &types, &attrs); +} + +fn packedStructInfo(comptime T: type) std.builtin.Type.Struct { + const info = @typeInfo(T).@"struct"; + if (info.layout != .@"packed") + @compileError("Packed requires a packed struct"); + if (info.backing_integer == null) + @compileError("Packed requires an integer-backed packed struct"); + return info; +} + +test "Packed: reflected fields and nested tagged union" { + const Tag = enum(u2) { first, second, third }; + const Value = packed union { + number: packed struct(u4) { + data: u3, + _pad: u1, + }, + flags: packed struct(u4) { + a: bool, + b: bool, + _pad: u2, + }, + }; + const Nested = packed struct(u1) { enabled: bool }; + const Subject = packed struct(u8) { + tag: Tag, + value: Value, + nested: Nested, + _padding: u1, + }; + + const Number = Packed(@FieldType(Value, "number"), .{ .fields = .{ + .data = .{ .name = "number" }, + ._pad = .{ .omit = true }, + } }); + const Flags = Packed(@FieldType(Value, "flags"), .{ .fields = .{ + ._pad = .{ .omit = true }, + } }); + const NestedLayout = Packed(Nested, .{}); + const ValueLayout = PackedTaggedUnion(Subject, .value, .tag, .{ .arms = .{ + .first = .{ .number = Number }, + .second = .{ .number = Number }, + .third = .{ .flags = Flags }, + } }); + const Layout = Packed(Subject, .{ .fields = .{ + .value = .{ .encoding = .{ .tagged_union = ValueLayout } }, + .nested = .{ .encoding = .{ .@"packed" = NestedLayout } }, + ._padding = .{ .omit = true }, + } }); + + try testing.expectEqual(@bitOffsetOf(Subject, "value"), Layout.bitOffset(.value)); + try testing.expectEqual(@bitSizeOf(Value), Layout.bitWidth(.value)); + try testing.expectEqualStrings("number", Number.fieldName(.data).?); + try testing.expect(Number.fieldName(._pad) == null); + switch (ValueLayout.arm(.first).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + switch (ValueLayout.arm(.second).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + try testing.expectEqual(@as(usize, 1), NestedLayout.bitWidth(.enabled)); +} diff --git a/vendor/libghostty-vt/src/lib/string.zig b/vendor/libghostty-vt/src/lib/string.zig deleted file mode 100644 index 795823c2..00000000 --- a/vendor/libghostty-vt/src/lib/string.zig +++ /dev/null @@ -1,15 +0,0 @@ -const std = @import("std"); - -// This is a copy of std.mem.cutPrefix from 0.16. Once Ghostty has been ported -// to 0.16 this can be removed. - -/// If slice starts with prefix, returns the rest of slice starting at -/// prefix.len. -pub fn cutPrefix(comptime T: type, slice: []const T, prefix: []const T) ?[]const T { - return if (std.mem.startsWith(T, slice, prefix)) slice[prefix.len..] else null; -} - -test cutPrefix { - try std.testing.expectEqualStrings("foo", cutPrefix(u8, "--example=foo", "--example=").?); - try std.testing.expectEqual(null, cutPrefix(u8, "--example=foo", "-example=")); -} diff --git a/vendor/libghostty-vt/src/lib/struct.zig b/vendor/libghostty-vt/src/lib/struct.zig index bbf07ece..262e037f 100644 --- a/vendor/libghostty-vt/src/lib/struct.zig +++ b/vendor/libghostty-vt/src/lib/struct.zig @@ -20,23 +20,21 @@ pub fn Struct( .zig => Zig, .c => c: { const info = @typeInfo(Zig).@"struct"; - var fields: [info.fields.len]std.builtin.Type.StructField = undefined; - for (info.fields, 0..) |field, i| { - fields[i] = .{ - .name = field.name, - .type = field.type, + var names: [info.fields.len][]const u8 = undefined; + var types: [info.fields.len]type = undefined; + var attrs: [info.fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (info.fields, &names, &types, &attrs) |field, *name, *ty, *attr| { + name.* = field.name; + ty.* = field.type; + attr.* = .{ + .@"align" = field.alignment, + .@"comptime" = field.is_comptime, .default_value_ptr = field.default_value_ptr, - .is_comptime = field.is_comptime, - .alignment = if (field.alignment > 0) field.alignment else @alignOf(field.type), }; } - break :c @Type(.{ .@"struct" = .{ - .layout = .@"extern", - .fields = &fields, - .decls = &.{}, - .is_tuple = info.is_tuple, - } }); + break :c @Struct(.@"extern", null, &names, &types, &attrs); }, }; } diff --git a/vendor/libghostty-vt/src/lib/tinyio/posix.zig b/vendor/libghostty-vt/src/lib/tinyio/posix.zig new file mode 100644 index 00000000..10b96210 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/tinyio/posix.zig @@ -0,0 +1,608 @@ +//! POSIX impl of TinyIo: plain blocking syscalls cribbed from +//! std.posix and the POSIX impls of `std.Io.Threaded`. + +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; + +// 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, +}; + +pub fn randomSecure(buffer: []u8) Io.RandomSecureError!void { + // The same sources as `std.Io.Threaded.randomSecure` minus + // cancelation and the /dev/urandom fallback: arc4random_buf where + // libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise + // the getrandom syscall on Linux. Anything else has no entropy. + if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) { + posix.system.arc4random_buf(buffer.ptr, buffer.len); + return; + } + + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + var i: usize = 0; + while (i < buffer.len) { + const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0); + switch (linux.errno(rc)) { + .SUCCESS => i += rc, + .INTR => continue, + else => return error.EntropyUnavailable, + } + } + return; + } + + return error.EntropyUnavailable; +} + +pub 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); +} + +pub 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; +} + +pub fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { + for (dirs) |dir| closeFd(dir.handle); +} + +pub fn fileClose(_: ?*anyopaque, files: []const File) void { + for (files) |file| closeFd(file.handle); +} + +pub 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), + } + } +} + +pub 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]; +} + +pub 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), + } + } +} + +pub 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), + } + } +} + +pub 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), + } + } +} + +pub 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), + } + } +} + +pub 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, + } +} + +pub 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); +} + +pub 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), + } + } +} + +pub 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); + }, + } +} + +pub 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 => {}, + } +} diff --git a/vendor/libghostty-vt/src/lib/tinyio/test.zig b/vendor/libghostty-vt/src/lib/tinyio/test.zig new file mode 100644 index 00000000..c6b4d96f --- /dev/null +++ b/vendor/libghostty-vt/src/lib/tinyio/test.zig @@ -0,0 +1,836 @@ +//! Tests for TinyIo that only touch its public surface, so they run on +//! every supported platform. Platform-specific tests live next to the +//! platform arm they exercise. + +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; +const TinyIo = @import("../TinyIo.zig"); +const supported = TinyIo.supported; + +const is_windows = builtin.os.tag == .windows; +const windows = std.os.windows; +const os_windows = @import("../../os/windows.zig"); +const ntdll = os_windows.exp.ntdll; +const kernel32 = os_windows.exp.kernel32; +const HANDLE = windows.HANDLE; + +/// Reads until every buffer is full or the stream ends. POSIX readv fills +/// all buffers in one call; NT reads one buffer per call. +fn readStreamingAll(file: File, io_: Io, buffers: []const []u8) !usize { + var remaining: [8][]u8 = undefined; + std.debug.assert(buffers.len <= remaining.len); + @memcpy(remaining[0..buffers.len], buffers); + var list: [][]u8 = remaining[0..buffers.len]; + + var total: usize = 0; + while (list.len > 0) { + var n = file.readStreaming(io_, list) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + total += n; + while (list.len > 0 and n >= list[0].len) { + n -= list[0].len; + list = list[1..]; + } + if (list.len > 0) list[0] = list[0][n..]; + } + return total; +} + +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, .windows => {}, + 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 "randomSecure fills with fresh entropy" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var a: [32]u8 = @splat(0); + var b: [32]u8 = @splat(0); + try test_io.randomSecure(&a); + try test_io.randomSecure(&b); + + // Non-zero and non-repeating. A zero fill is what `random` does + // without a source, which would make every one-time password the + // same; identical draws would mean the same thing. + try testing.expect(!std.mem.allEqual(u8, &a, 0)); + try testing.expect(!std.mem.allEqual(u8, &b, 0)); + try testing.expect(!std.mem.eql(u8, &a, &b)); + + // Zero-length is a no-op. + try test_io.randomSecure(a[0..0]); +} + +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 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. NT reports this + // as the path not existing (Threaded does too) rather than ENOTDIR. + try testing.expectError( + if (comptime is_windows) error.FileNotFound else 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 (one readv on POSIX, one NtReadFile per + // buffer on Windows). + var a: [6]u8 = undefined; + var b: [4]u8 = undefined; + var slices = [_][]u8{ &a, &b }; + try testing.expectEqual(@as(usize, 10), try readStreamingAll(file, 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 readStreamingAll(file, test_io, &slices)); + try testing.expectEqualStrings("stream", &a); +} + +test "pipes: streaming works; positional and seek are Unseekable or ignored" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + const msg = "through the pipe"; + const read_end: File = if (comptime is_windows) pipe: { + var read_h: HANDLE = undefined; + var write_h: HANDLE = undefined; + if (!kernel32.CreatePipe(&read_h, &write_h, null, 0).toBool()) { + return error.SkipZigTest; + } + defer _ = ntdll.NtClose(write_h); + // Twice, so a second read after the streaming one has data. + for (0..2) |_| { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + try testing.expectEqual(.SUCCESS, windows.ntdll.NtWriteFile( + write_h, + null, + null, + null, + &iosb, + msg.ptr, + msg.len, + null, + null, + )); + } + break :pipe .{ .handle = read_h, .flags = .{ .nonblocking = false } }; + } else pipe: { + var fds: [2]posix.fd_t = undefined; + switch (posix.errno(posix.system.pipe(&fds))) { + .SUCCESS => {}, + else => return error.SkipZigTest, + } + defer _ = posix.system.close(fds[1]); + try testing.expectEqual( + @as(isize, msg.len), + @as(isize, @intCast(posix.system.write(fds[1], msg, msg.len))), + ); + break :pipe .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; + }; + defer read_end.close(test_io); + + // 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); + + if (comptime is_windows) { + // NT pipes accept a byte offset and a file position but ignore + // both: reads stay sequential and seeks succeed without effect. + // Threaded behaves the same way, so TinyIo mirrors it rather than + // spending a syscall per read to detect pipes. + @memset(&buf, 0); + try testing.expectEqual(@as(usize, msg.len), try test_io.vtable.fileReadPositional( + test_io.userdata, + read_end, + &slices, + 8, + )); + try testing.expectEqualStrings(msg, &buf); + try test_io.vtable.fileSeekTo(test_io.userdata, read_end, 0); + try test_io.vtable.fileSeekBy(test_io.userdata, read_end, 1); + + // Drained with the writer closed is the end of the stream. + try testing.expectError(error.EndOfStream, read_end.readStreaming(test_io, &slices)); + return; + } + + // 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, .windows => {}, + 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. Creating one + // on Windows needs a privilege that plain users and CI lack. + try tmp_dir.dir.writeFile(testing.io, .{ + .sub_path = "target.txt", + .data = "x", + }); + tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}) catch |err| switch (err) { + error.AccessDenied => if (comptime is_windows) return error.SkipZigTest else return err, + else => return err, + }; + 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}); + + if (comptime is_windows) { + // A query on a closed handle fails without raising anything. + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var info: os_windows.FILE_STANDARD_INFORMATION = undefined; + try testing.expectEqual(.INVALID_HANDLE, ntdll.NtQueryInformationFile( + dir.handle, + &iosb, + &info, + @sizeOf(os_windows.FILE_STANDARD_INFORMATION), + .Standard, + )); + return; + } + + // 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. `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" { + 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/vendor/libghostty-vt/src/lib/tinyio/windows.zig b/vendor/libghostty-vt/src/lib/tinyio/windows.zig new file mode 100644 index 00000000..a815d266 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/tinyio/windows.zig @@ -0,0 +1,745 @@ +//! Windows (NT) impl of TinyIo. +//! +//! Modeled on the Windows impl of `std.Io.Threaded`, minus cancelation and +//! the sleep-and-retry loops it wraps around two kernel quirks (TinyIo +//! cannot sleep, so those statuses surface as `error.FileBusy`). Everything +//! goes through ntdll and kernel32, which static consumers already link. +//! Paths arrive as WTF-8 and are converted to the NT-prefixed WTF-16 form +//! `NtCreateFile` expects, the same way Threaded converts them. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const File = Io.File; +const Dir = Io.Dir; +const Threaded = std.Io.Threaded; +const TinyIo = @import("../TinyIo.zig"); +const windows = std.os.windows; +const os_windows = @import("../../os/windows.zig"); +const ntdll = os_windows.exp.ntdll; +const kernel32 = os_windows.exp.kernel32; +const HANDLE = windows.HANDLE; + +const nt_prefix = [_]u16{ '\\', '?', '?', '\\' }; +const unc_nt_prefix = [_]u16{ '\\', '?', '?', '\\', 'U', 'N', 'C', '\\' }; +const share_all = os_windows.FILE_SHARE_READ | + os_windows.FILE_SHARE_WRITE | + os_windows.FILE_SHARE_DELETE; + +/// A WTF-16 path buffer sized for the longest path NT accepts, plus one +/// slot for the terminator the Rtl path functions want. The array is +/// deliberately not a sentinel type: `undefined` sentinel arrays make the +/// compiler materialize a 64 KiB constant image of the buffer (to place +/// the sentinel) in .rdata, which cost ~200 KB across three buffers. +const WindowsPath = struct { + data: [windows.PATH_MAX_WIDE + 1]u16, + len: usize, + + fn span(self: *const WindowsPath) [:0]const u16 { + return self.data[0..self.len :0]; + } + + fn setLen(self: *WindowsPath, len: usize) void { + self.len = len; + self.data[len] = 0; + } + + /// True for absolute NT paths (`\??\...`). These must be opened with + /// a null root directory; anything else is relative to a handle. + fn isNt(self: *const WindowsPath) bool { + return windows.hasCommonNtPrefix(u16, self.span()); + } + + /// Rewrites the Win32 path produced by RtlGetFullPathName_U into NT + /// form in place: `\\?\X` and `\\.\X` become `\??\X`, `\\server\share` + /// becomes `\??\UNC\server\share`, and `C:\...` gets `\??\` in front. + fn win32ToNt(self: *WindowsPath) error{NameTooLong}!void { + const p = self.data[0..self.len]; + const sep: u16 = '\\'; + if (p.len >= 4 and p[0] == sep and p[1] == sep and + (p[2] == '?' or p[2] == '.') and p[3] == sep) + { + self.data[0..nt_prefix.len].* = nt_prefix; + return; + } + if (p.len >= 2 and p[0] == sep and p[1] == sep) { + const rest_len = p.len - 2; + if (unc_nt_prefix.len + rest_len > windows.PATH_MAX_WIDE) return error.NameTooLong; + @memmove(self.data[unc_nt_prefix.len..][0..rest_len], p[2..]); + self.data[0..unc_nt_prefix.len].* = unc_nt_prefix; + self.setLen(unc_nt_prefix.len + rest_len); + return; + } + if (nt_prefix.len + p.len > windows.PATH_MAX_WIDE) return error.NameTooLong; + @memmove(self.data[nt_prefix.len..][0..p.len], p); + self.data[0..nt_prefix.len].* = nt_prefix; + self.setLen(nt_prefix.len + p.len); + } +}; + +const PathError = Dir.PathNameError || Io.UnexpectedError; + +/// Converts a WTF-8 path into what `NtCreateFile` accepts, the way +/// `std.Io.Threaded.sliceToPrefixedFileW` does: NT paths (`\??\...`) pass +/// through, relative paths are normalized and stay relative to `dir`, +/// and everything else (drive-absolute, drive-relative, rooted, UNC, +/// `\\.\` and `\\?\` device paths, plus relative paths with more `..` +/// components than can be removed) is resolved to an absolute `\??\` +/// path through RtlGetFullPathName_U. `dir` matters only for that last +/// case; the process working directory is what Rtl resolves against. +/// +/// The result is written to `out`, which is 64 KiB, so callers keep it +/// rather than having it returned by value. +fn pathToNt(dir: ?HANDLE, path: []const u8, out: *WindowsPath) PathError!void { + out.setLen(try windows.wtf8ToWtf16Le(out.data[0..windows.PATH_MAX_WIDE], path)); + if (out.isNt()) return; + + const path_type = std.fs.path.getWin32PathType(u16, out.span()); + switch (path_type) { + .relative => { + if (windows.normalizePath(u16, out.data[0..out.len])) |len| { + out.setLen(len); + return; + } else |err| switch (err) { + // Escapes the directory; resolved to an absolute path below. + error.TooManyParentDirs => {}, + } + }, + .root_local_device => { + // `\\.` and `\\?` are the NT prefix and nothing else. + out.data[0..nt_prefix.len].* = nt_prefix; + out.setLen(nt_prefix.len); + return; + }, + else => {}, + } + + // RtlGetFullPathName_U resolves against the process working directory, + // so a relative path against any other directory handle is anchored to + // that directory's final path first. + var full: [windows.PATH_MAX_WIDE + 1]u16 = undefined; + var full_len: usize = 0; + if (path_type == .relative) anchor: { + const dir_handle = dir orelse break :anchor; + if (dir_handle == Dir.cwd().handle) break :anchor; + const dir_path = finalPath(dir_handle, &full) catch |err| switch (err) { + error.NameTooLong => return error.NameTooLong, + else => return error.Unexpected, + }; + full_len = dir_path.len; + full[full_len] = '\\'; + full_len += 1; + } + if (full_len + out.len > windows.PATH_MAX_WIDE) return error.NameTooLong; + @memcpy(full[full_len..][0..out.len], out.span()); + full_len += out.len; + full[full_len] = 0; + + const byte_len = ntdll.RtlGetFullPathName_U( + full[0..full_len :0].ptr, + @intCast(windows.PATH_MAX_WIDE * 2), + &out.data, + null, + ); + if (byte_len == 0) return error.BadPathName; + if (byte_len / 2 > windows.PATH_MAX_WIDE) return error.NameTooLong; + out.setLen(byte_len / 2); + try out.win32ToNt(); +} + +const FinalPathError = error{ + FileNotFound, + AccessDenied, + NameTooLong, + SystemResources, +} || Io.UnexpectedError; + +/// The canonical Win32 path of an open handle from GetFinalPathNameByHandleW +/// with the `\\?\` prefix removed, which is the form Threaded's realpath +/// returns and the Kitty graphics path validation expects: `\\?\C:\x` +/// becomes `C:\x` and `\\?\UNC\srv\share\x` becomes `\\srv\share\x`. +/// The result aliases `buf`. +fn finalPath(handle: HANDLE, buf: *[windows.PATH_MAX_WIDE + 1]u16) FinalPathError![]u16 { + const len = kernel32.GetFinalPathNameByHandleW( + handle, + buf, + @intCast(buf.len), + os_windows.FILE_NAME_NORMALIZED | os_windows.VOLUME_NAME_DOS, + ); + if (len == 0) return switch (os_windows.GetLastError()) { + .FILE_NOT_FOUND, .PATH_NOT_FOUND, .INVALID_HANDLE => error.FileNotFound, + .ACCESS_DENIED => error.AccessDenied, + .NOT_ENOUGH_MEMORY => error.SystemResources, + else => |err| os_windows.unexpectedError(err), + }; + // A too-small buffer reports the size needed, terminator included. + if (len > windows.PATH_MAX_WIDE) return error.NameTooLong; + + const path = buf[0..len]; + if (path.len >= 4 and path[0] == '\\' and path[1] == '\\' and + path[2] == '?' and path[3] == '\\') + { + // `\\?\` is the Win32 spelling of `\??\`; rewrite it so the std + // helper strips it and folds `UNC\` back into `\\`. + path[1] = '?'; + return windows.ntToWin32Namespace(path, path) catch |err| switch (err) { + error.NotNtPath => unreachable, + error.NameTooLong => error.NameTooLong, + }; + } + return path; +} + +fn realPathHandle(handle: HANDLE, out_buffer: []u8) File.RealPathError!usize { + var wide: [windows.PATH_MAX_WIDE + 1]u16 = undefined; + const path = try finalPath(handle, &wide); + if (std.unicode.calcWtf8Len(path) > out_buffer.len) return error.NameTooLong; + return std.unicode.wtf16LeToWtf8(out_buffer, path); +} + +const OpenNtError = error{ + BadPathName, + FileNotFound, + NetworkNotFound, + NoDevice, + AccessDenied, + PipeBusy, + PathAlreadyExists, + IsDir, + NotDir, + AntivirusInterference, + FileBusy, +} || Io.UnexpectedError; + +/// Opens an existing file or directory. `path` is relative to `dir` +/// unless it is an absolute NT path, in which case `dir` is ignored +/// (NtCreateFile rejects a root directory paired with an absolute name). +fn openNt( + dir: HANDLE, + path: *const WindowsPath, + access: windows.ACCESS_MASK, + options: u32, +) OpenNtError!HANDLE { + var attr: os_windows.OBJECT_ATTRIBUTES = .{ + .RootDirectory = if (path.isNt()) null else dir, + .ObjectName = @constCast(&windows.UNICODE_STRING.init(path.span())), + }; + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var handle: HANDLE = undefined; + return switch (ntdll.NtCreateFile( + &handle, + access, + &attr, + &iosb, + null, + os_windows.FILE_ATTRIBUTE_NORMAL, + share_all, + os_windows.FILE_OPEN, + options, + null, + 0, + )) { + .SUCCESS => handle, + .OBJECT_NAME_INVALID, .OBJECT_PATH_SYNTAX_BAD => error.BadPathName, + .OBJECT_NAME_NOT_FOUND, .OBJECT_PATH_NOT_FOUND => error.FileNotFound, + .BAD_NETWORK_PATH, .BAD_NETWORK_NAME => error.NetworkNotFound, + .NO_MEDIA_IN_DEVICE, .PIPE_NOT_AVAILABLE => error.NoDevice, + .ACCESS_DENIED, .USER_MAPPED_FILE => error.AccessDenied, + .PIPE_BUSY => error.PipeBusy, + .OBJECT_NAME_COLLISION => error.PathAlreadyExists, + .FILE_IS_A_DIRECTORY => error.IsDir, + .NOT_A_DIRECTORY => error.NotDir, + .VIRUS_INFECTED, .VIRUS_DELETED => error.AntivirusInterference, + // Threaded sleeps and retries these (a kernel bug with recently + // closed executables, and deletes still in progress). + .SHARING_VIOLATION, .DELETE_PENDING => error.FileBusy, + else => |status| os_windows.unexpectedStatus(status), + }; +} + +pub fn dirOpenFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + options: Dir.OpenFileOptions, +) File.OpenError!File { + // Same as the POSIX impl: nothing in the terminal locks files. + if (options.lock != .none) return error.FileLocksUnsupported; + var path: WindowsPath = undefined; + try pathToNt(dir.handle, sub_path, &path); + + // Directories can never be opened for writing, and `.` and `..` + // always name a directory. + const allow_directory = options.allow_directory and !options.isWrite(); + if (!allow_directory and (std.mem.eql(u16, path.span(), &.{'.'}) or + std.mem.eql(u16, path.span(), &.{ '.', '.' }))) + { + return error.IsDir; + } + + var flags: u32 = os_windows.FILE_SYNCHRONOUS_IO_NONALERT; + if (!allow_directory) flags |= os_windows.FILE_NON_DIRECTORY_FILE; + if (!options.follow_symlinks) flags |= os_windows.FILE_OPEN_REPARSE_POINT; + const handle = try openNt(dir.handle, &path, .{ + .STANDARD = .{ .SYNCHRONIZE = true }, + .GENERIC = .{ .READ = options.isRead(), .WRITE = options.isWrite() }, + }, flags); + return .{ .handle = handle, .flags = .{ .nonblocking = false } }; +} + +pub fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { + for (dirs) |dir| _ = ntdll.NtClose(dir.handle); +} + +pub fn fileClose(_: ?*anyopaque, files: []const File) void { + for (files) |file| _ = ntdll.NtClose(file.handle); +} + +pub fn fileStat(_: ?*anyopaque, file: File) File.StatError!File.Stat { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var info: os_windows.FILE_ALL_INFORMATION = undefined; + switch (ntdll.NtQueryInformationFile( + file.handle, + &iosb, + &info, + @sizeOf(os_windows.FILE_ALL_INFORMATION), + .All, + )) { + // The trailing name is variable length and unused, so an + // overflow only means it was truncated. + .SUCCESS, .BUFFER_OVERFLOW => {}, + .ACCESS_DENIED => return error.AccessDenied, + else => |status| return os_windows.unexpectedStatus(status), + } + + const kind: File.Kind = kind: { + if (info.BasicInformation.FileAttributes.REPARSE_POINT) { + var tag: os_windows.FILE_ATTRIBUTE_TAG_INFORMATION = undefined; + switch (ntdll.NtQueryInformationFile( + file.handle, + &iosb, + &tag, + @sizeOf(os_windows.FILE_ATTRIBUTE_TAG_INFORMATION), + .AttributeTag, + )) { + .SUCCESS => {}, + .ACCESS_DENIED => return error.AccessDenied, + else => |status| return os_windows.unexpectedStatus(status), + } + break :kind if (tag.ReparseTag.IsSurrogate) .sym_link else .unknown; + } + break :kind if (info.BasicInformation.FileAttributes.DIRECTORY) + .directory + else + .file; + }; + + return .{ + .inode = info.InternalInformation.IndexNumber, + .size = @as(u64, @bitCast(info.StandardInformation.EndOfFile)), + .permissions = .default_file, + .kind = kind, + .atime = windows.fromSysTime(info.BasicInformation.LastAccessTime), + .mtime = windows.fromSysTime(info.BasicInformation.LastWriteTime), + .ctime = windows.fromSysTime(info.BasicInformation.ChangeTime), + .nlink = info.StandardInformation.NumberOfLinks, + .block_size = @intCast(std.heap.page_size_max), + }; +} + +pub fn fileLength(_: ?*anyopaque, file: File) File.LengthError!u64 { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var info: os_windows.FILE_STANDARD_INFORMATION = undefined; + return switch (ntdll.NtQueryInformationFile( + file.handle, + &iosb, + &info, + @sizeOf(os_windows.FILE_STANDARD_INFORMATION), + .Standard, + )) { + .SUCCESS => @as(u64, @bitCast(info.EndOfFile)), + .ACCESS_DENIED => error.AccessDenied, + else => |status| os_windows.unexpectedStatus(status), + }; +} + +/// One NtReadFile into one buffer. A positional read supplies an explicit +/// byte offset; a streaming read uses and advances the handle's own file +/// position (TinyIo only opens synchronous handles, which track it). +fn ntRead(comptime positional: bool, handle: HANDLE, buffer: []u8, offset: u64) !usize { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var signed_offset: windows.LARGE_INTEGER = undefined; + const offset_ptr: ?*const windows.LARGE_INTEGER = if (positional) o: { + signed_offset = std.math.cast(i64, offset) orelse return error.Unseekable; + break :o &signed_offset; + } else null; + return switch (ntdll.NtReadFile( + handle, + null, + null, + null, + &iosb, + buffer.ptr, + std.math.lossyCast(u32, buffer.len), + offset_ptr, + null, + )) { + .SUCCESS => iosb.Information, + .END_OF_FILE, .PIPE_BROKEN => error.EndOfStream, + .INVALID_HANDLE => error.NotOpenForReading, + .INVALID_DEVICE_REQUEST => error.IsDir, + .FILE_LOCK_CONFLICT => error.LockViolation, + .ACCESS_DENIED => error.AccessDenied, + // Pipes and devices reject explicit offsets. + .INVALID_PARAMETER => |status| if (positional) + error.Unseekable + else + os_windows.unexpectedStatus(status), + // Only asynchronous handles complete later, and TinyIo never + // creates one; returning here would hand the kernel a dead buffer. + .PENDING => unreachable, + else => |status| os_windows.unexpectedStatus(status), + }; +} + +pub fn fileReadPositional( + _: ?*anyopaque, + file: File, + data: []const []u8, + offset: u64, +) File.ReadPositionalError!usize { + // NtReadFile takes one buffer, so a scatter read is issued one buffer + // at a time, bounded like the POSIX iovec path, stopping at the first + // short read. Positional reads only make sense on seekable files, so + // the extra calls cannot block. + var total: usize = 0; + var count: usize = 0; + for (data) |buffer| { + if (buffer.len == 0) continue; + if (count == Threaded.max_iovecs_len) break; + count += 1; + const n = ntRead(true, file.handle, buffer, offset + total) catch |err| switch (err) { + error.EndOfStream => break, + else => |e| return e, + }; + total += n; + if (n < buffer.len) break; + } + return total; +} + +pub fn fileReadStreaming( + file: File, + data: []const []u8, +) Io.Operation.FileReadStreaming.Error!usize { + // Like Threaded, a streaming read fills one buffer per call: a second + // NtReadFile could block on a pipe that already delivered data. + for (data) |buffer| { + if (buffer.len == 0) continue; + return ntRead(false, file.handle, buffer, 0); + } + return 0; +} + +fn setFilePosition( + handle: HANDLE, + info: *os_windows.FILE_POSITION_INFORMATION, +) File.SeekError!void { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + return switch (ntdll.NtSetInformationFile( + handle, + &iosb, + info, + @sizeOf(os_windows.FILE_POSITION_INFORMATION), + .Position, + )) { + .SUCCESS => {}, + .ACCESS_DENIED => error.AccessDenied, + .PIPE_NOT_AVAILABLE, .INVALID_PARAMETER, .INVALID_DEVICE_REQUEST => error.Unseekable, + else => |status| os_windows.unexpectedStatus(status), + }; +} + +pub fn fileSeekBy(_: ?*anyopaque, file: File, offset: i64) File.SeekError!void { + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var info: os_windows.FILE_POSITION_INFORMATION = undefined; + switch (ntdll.NtQueryInformationFile( + file.handle, + &iosb, + &info, + @sizeOf(os_windows.FILE_POSITION_INFORMATION), + .Position, + )) { + .SUCCESS => {}, + .ACCESS_DENIED => return error.AccessDenied, + .PIPE_NOT_AVAILABLE, .INVALID_PARAMETER, .INVALID_DEVICE_REQUEST => return error.Unseekable, + else => |status| return os_windows.unexpectedStatus(status), + } + const current: u64 = @bitCast(info.CurrentByteOffset); + const target = if (offset >= 0) + std.math.add(u64, current, @intCast(offset)) + else + std.math.sub(u64, current, @abs(offset)); + info.CurrentByteOffset = @bitCast(target catch return error.Unseekable); + return setFilePosition(file.handle, &info); +} + +pub fn fileSeekTo(_: ?*anyopaque, file: File, offset: u64) File.SeekError!void { + var info: os_windows.FILE_POSITION_INFORMATION = .{ + .CurrentByteOffset = @bitCast(offset), + }; + return setFilePosition(file.handle, &info); +} + +pub fn fileRealPath( + _: ?*anyopaque, + file: File, + out_buffer: []u8, +) File.RealPathError!usize { + return realPathHandle(file.handle, out_buffer); +} + +pub fn dirRealPathFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, + out_buffer: []u8, +) Dir.RealPathFileError!usize { + var path: WindowsPath = undefined; + try pathToNt(dir.handle, sub_path, &path); + const handle = try openNt(dir.handle, &path, .{ + .STANDARD = .{ .SYNCHRONIZE = true }, + .GENERIC = .{ .READ = true }, + }, os_windows.FILE_SYNCHRONOUS_IO_NONALERT); + defer _ = ntdll.NtClose(handle); + + // The path buffer is free again, so it holds the wide result. + const final = try finalPath(handle, &path.data); + if (std.unicode.calcWtf8Len(final) > out_buffer.len) return error.NameTooLong; + return std.unicode.wtf16LeToWtf8(out_buffer, final); +} + +pub fn dirDeleteFile( + _: ?*anyopaque, + dir: Dir, + sub_path: []const u8, +) Dir.DeleteFileError!void { + // The parent cannot be removed through a handle inside it. + if (std.mem.eql(u8, sub_path, "..")) return error.FileBusy; + var path: WindowsPath = undefined; + try pathToNt(dir.handle, sub_path, &path); + // NT has no `.`, but an empty name reopens `dir` itself. + if (std.mem.eql(u8, sub_path, ".")) path.setLen(0); + + const handle = openNt(dir.handle, &path, .{ + .STANDARD = .{ .RIGHTS = .{ .DELETE = true }, .SYNCHRONIZE = true }, + }, os_windows.FILE_SYNCHRONOUS_IO_NONALERT | + os_windows.FILE_NON_DIRECTORY_FILE | + os_windows.FILE_OPEN_REPARSE_POINT) catch |err| switch (err) { + // Not produced by a plain file open, and not in DeleteFileError. + error.PipeBusy, + error.NoDevice, + error.PathAlreadyExists, + error.AntivirusInterference, + => return error.Unexpected, + else => |e| return e, + }; + defer _ = ntdll.NtClose(handle); + + // Prefer POSIX delete semantics (the name is gone immediately, even + // with other handles open, e.g. an antivirus scan of a temp file) and + // fall back to delete-on-close where the kernel or file system does + // not support them. + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var ex: os_windows.FILE_DISPOSITION_INFORMATION_EX = .{ .Flags = .{ + .DELETE = true, + .POSIX_SEMANTICS = true, + .IGNORE_READONLY_ATTRIBUTE = true, + } }; + const status = switch (ntdll.NtSetInformationFile( + handle, + &iosb, + &ex, + @sizeOf(os_windows.FILE_DISPOSITION_INFORMATION_EX), + .DispositionEx, + )) { + .INVALID_PARAMETER, .INVALID_INFO_CLASS, .NOT_SUPPORTED => fallback: { + var info: os_windows.FILE_DISPOSITION_INFORMATION = .{ .DeleteFile = .TRUE }; + break :fallback ntdll.NtSetInformationFile( + handle, + &iosb, + &info, + @sizeOf(os_windows.FILE_DISPOSITION_INFORMATION), + .Disposition, + ); + }, + else => |status| status, + }; + switch (status) { + .SUCCESS => {}, + .CANNOT_DELETE, .MEDIA_WRITE_PROTECTED, .ACCESS_DENIED => return error.AccessDenied, + else => |s| return os_windows.unexpectedStatus(s), + } +} + +pub fn randomSecure(buffer: []u8) Io.RandomSecureError!void { + // Read the kernel CSPRNG device directly, which is what ProcessPrng + // and BCryptGenRandom draw from and what Threaded reads through a + // cached handle. TinyIo is stateless, so the device is opened per + // call: three syscalls, fine for one-time password generation, and + // no bcryptprimitives or advapi32 import for static consumers. + var name: windows.UNICODE_STRING = .init( + std.unicode.utf8ToUtf16LeStringLiteral("\\Device\\CNG"), + ); + var attr: os_windows.OBJECT_ATTRIBUTES = .{ .ObjectName = &name }; + var iosb: os_windows.IO_STATUS_BLOCK = undefined; + var handle: HANDLE = undefined; + switch (ntdll.NtOpenFile( + &handle, + .{ + .STANDARD = .{ .SYNCHRONIZE = true }, + .SPECIFIC = .{ .FILE = .{ .READ_DATA = true } }, + }, + &attr, + &iosb, + share_all, + os_windows.FILE_SYNCHRONOUS_IO_NONALERT, + )) { + .SUCCESS => {}, + else => return error.EntropyUnavailable, + } + defer _ = ntdll.NtClose(handle); + + var i: usize = 0; + while (i < buffer.len) { + const len = std.math.lossyCast(u32, buffer.len - i); + switch (ntdll.NtDeviceIoControlFile( + handle, + null, + null, + null, + &iosb, + os_windows.IOCTL_KSEC_GEN_RANDOM, + null, + 0, + buffer[i..].ptr, + len, + )) { + .SUCCESS => i += len, + else => return error.EntropyUnavailable, + } + } +} + +pub fn futexWaitInner(ptr: *const u32, expected: u32, timeout_ns: ?u64) void { + @branchHint(.cold); + if (builtin.single_threaded) unreachable; // nobody would ever wake us + + // RtlWaitOnAddress is what kernel32's WaitOnAddress forwards + // to; going through ntdll keeps static consumers off + // synchronization.lib. A negative timeout is relative in + // 100ns units and null waits forever. It returns SUCCESS on a + // wake or when the value already differs and TIMEOUT + // otherwise; either way the caller re-checks its condition. + var interval: windows.LARGE_INTEGER = undefined; + const timeout: ?*const windows.LARGE_INTEGER = if (timeout_ns) |ns| t: { + interval = -@as(i64, @intCast(@min(ns / 100, std.math.maxInt(i64)))); + break :t &interval; + } else null; + _ = ntdll.RtlWaitOnAddress(ptr, &expected, @sizeOf(u32), timeout); +} + +pub fn futexWake(userdata: ?*anyopaque, ptr: *const u32, max_waiters: u32) void { + @branchHint(.cold); + _ = userdata; + if (builtin.single_threaded) return; // nothing to wake up + if (max_waiters > 1) ntdll.RtlWakeAddressAll(ptr) else ntdll.RtlWakeAddressSingle(ptr); +} + +test "windows: path conversion" { + const testing = std.testing; + const L = std.unicode.utf8ToUtf16LeStringLiteral; + const cwd = Dir.cwd().handle; + + // Absolute Win32 paths are canonicalized and NT-prefixed, with either + // separator and `..` resolved. + var p: WindowsPath = undefined; + try pathToNt(cwd, "C:/foo/../bar", &p); + try testing.expectEqualSlices(u16, L("\\??\\C:\\bar"), p.span()); + try pathToNt(cwd, "\\\\server\\share\\x\\..\\y", &p); + try testing.expectEqualSlices(u16, L("\\??\\UNC\\server\\share\\y"), p.span()); + try pathToNt(cwd, "\\\\?\\C:\\x", &p); + try testing.expectEqualSlices(u16, L("\\??\\C:\\x"), p.span()); + try pathToNt(cwd, "\\\\.\\pipe\\x", &p); + try testing.expectEqualSlices(u16, L("\\??\\pipe\\x"), p.span()); + + // NT paths pass through untouched. + try pathToNt(cwd, "\\??\\C:\\x", &p); + try testing.expectEqualSlices(u16, L("\\??\\C:\\x"), p.span()); + + // Relative paths stay relative to the directory handle, normalized. + try pathToNt(cwd, "a/../b\\c", &p); + try testing.expectEqualSlices(u16, L("b\\c"), p.span()); + try testing.expect(!p.isNt()); + + // Relative paths that climb out of the directory resolve against the + // handle's real path (or the working directory for cwd). + try pathToNt(cwd, "..\\up.txt", &p); + try testing.expect(p.isNt()); + try testing.expect(std.mem.endsWith(u16, p.span(), L("\\up.txt"))); + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + var dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path = dir_buf[0..try tmp_dir.dir.realPath(testing.io, &dir_buf)]; + const parent = std.fs.path.dirname(dir_path).?; + try pathToNt(tmp_dir.dir.handle, "..\\up.txt", &p); + var got_buf: [std.fs.max_path_bytes]u8 = undefined; + const got = got_buf[0..std.unicode.wtf16LeToWtf8(&got_buf, p.span())]; + try testing.expect(std.mem.startsWith(u8, got, "\\??\\")); + try testing.expectEqualStrings(parent, got[4 .. 4 + parent.len]); + try testing.expectEqualStrings("\\up.txt", got[4 + parent.len ..]); + + // Encoding errors are path errors. + try testing.expectError(error.BadPathName, pathToNt(cwd, "bad\xff", &p)); + try testing.expectError(error.NameTooLong, pathToNt(cwd, "a" ** (windows.PATH_MAX_WIDE + 1), &p)); +} + +test "windows: realPath resolves through symlinks" { + 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 = "target.txt", + .data = "target", + }); + // Needs SeCreateSymbolicLinkPrivilege, which plain users and CI lack. + tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}) catch |err| switch (err) { + error.AccessDenied => return error.SkipZigTest, + else => return err, + }; + const dir: Dir = .{ .handle = tmp_dir.dir.handle }; + + // Following the link (the default) opens the target and realPath + // resolves to it, which is what the Kitty path validation relies on. + var file = try dir.openFile(test_io, "link.txt", .{}); + defer file.close(test_io); + try testing.expectEqual(@as(u64, "target".len), try file.length(test_io)); + try testing.expectEqual(File.Kind.file, (try file.stat(test_io)).kind); + 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")); + + // Opening the link itself reports a symlink. + var link = try dir.openFile(test_io, "link.txt", .{ .follow_symlinks = false }); + defer link.close(test_io); + try testing.expectEqual(File.Kind.sym_link, (try link.stat(test_io)).kind); + + // dirRealPathFile through the link resolves the target too. + var path_buf2: [std.fs.max_path_bytes]u8 = undefined; + const path2 = path_buf2[0..try dir.realPathFile(test_io, "link.txt", &path_buf2)]; + try testing.expectEqualStrings(path, path2); +} diff --git a/vendor/libghostty-vt/src/lib/types.zig b/vendor/libghostty-vt/src/lib/types.zig index 655fdc22..fbb50af8 100644 --- a/vendor/libghostty-vt/src/lib/types.zig +++ b/vendor/libghostty-vt/src/lib/types.zig @@ -5,9 +5,13 @@ pub const String = extern struct { pub fn init(zig: anytype) String { return switch (@TypeOf(zig)) { []u8, []const u8 => .{ - .ptr = zig.ptr, + // Borrowed strings keep a non-null pointer, but it must point + // to real storage: empty Zig slices can use invalid sentinels + // that foreign runtimes reject even without dereferencing. + .ptr = if (zig.len == 0) "" else zig.ptr, .len = zig.len, }, + else => @compileError("unsupported String.init type: " ++ @typeName(@TypeOf(zig))), }; } }; @@ -17,3 +21,14 @@ pub const Buffer = extern struct { cap: usize = 0, len: usize = 0, }; + +test "String.init empty output" { + const std = @import("std"); + const empty = try std.testing.allocator.alloc(u8, 0); + defer std.testing.allocator.free(empty); + const str = String.init(empty); + try std.testing.expectEqual(@as(usize, 0), str.len); + // Empty borrowed strings use the same real storage as an empty literal, + // never Zig's zero-length allocation sentinel. + try std.testing.expectEqual(@as([*]const u8, ""), str.ptr); +} diff --git a/vendor/libghostty-vt/src/lib/union.zig b/vendor/libghostty-vt/src/lib/union.zig index 924d0e86..7ed3fc79 100644 --- a/vendor/libghostty-vt/src/lib/union.zig +++ b/vendor/libghostty-vt/src/lib/union.zig @@ -15,14 +15,18 @@ const Target = @import("target.zig").Target; /// be an enum created with the `Enum` function in this library, so that /// automatic C ABI compatibility is ensured. /// -/// The `Padding` type is a type that is always added to the C union -/// with the key `_padding`. This should be set to a type that has the size -/// and alignment needed to pad the C union to the expected size. This -/// should never change to ensure ABI compatibility. +/// `options.padding` is a type that is always added to the C union with the key +/// `_padding`. It should have the size and alignment needed to pad the C union +/// to the expected size and should never change to ensure ABI compatibility. +/// +/// Each tag has an optional field in `options.field_renames` that may rename its +/// C value field. Multiple tags may map to the same field when their C value +/// types are identical. Tags with no payload are omitted. This is used +/// for metadata and should match the C headers directly. pub fn TaggedUnion( comptime target: Target, comptime Union: type, - comptime Padding: type, + comptime options: TaggedUnionOptions(Union), ) type { return struct { comptime { @@ -30,7 +34,7 @@ pub fn TaggedUnion( .zig => {}, // For ABI compatibility, we expect that this is our union size. - .c => if (@sizeOf(CValue) != @sizeOf(Padding)) { + .c => if (@sizeOf(CValue) != @sizeOf(options.padding)) { @compileLog(@sizeOf(CValue)); @compileError("TaggedUnion CValue size does not match expected fixed size"); }, @@ -49,18 +53,35 @@ pub fn TaggedUnion( .c => extern struct { tag: Tag, value: CValue, + + /// Returns the public C value-union field name for `tag`, or + /// null when the tag has no value field. This is metadata only; + /// it does not rename fields in `CValue`. + pub fn cFieldRename(comptime tag: Tag) ?[]const u8 { + const tag_name = @tagName(tag); + const value = @field(@unionInit(Union, tag_name, undefined), tag_name); + + if (@field(options.field_renames, tag_name)) |name| return name; + if (@sizeOf(@TypeOf(value)) == 0) return null; + + return tag_name; + } }, }; /// The C ABI compatible union value type. pub const CValue = cvalue: { + @setEvalBranchQuota(10_000); switch (target) { .zig => break :cvalue extern struct {}, .c => {}, } const tag_fields = @typeInfo(Tag).@"enum".fields; - var union_fields: [tag_fields.len + 1]std.builtin.Type.UnionField = undefined; + var names: [tag_fields.len + 1][]const u8 = undefined; + var types: [tag_fields.len + 1]type = undefined; + var attrs: [tag_fields.len + 1]std.builtin.Type.UnionField.Attributes = undefined; + for (tag_fields, 0..) |field, i| { const action = @unionInit(Union, field.name, undefined); const Type = t: { @@ -74,25 +95,16 @@ pub fn TaggedUnion( break :t Type; }; - union_fields[i] = .{ - .name = field.name, - .type = Type, - .alignment = @alignOf(Type), - }; + names[i] = field.name; + types[i] = Type; + attrs[i] = .{ .@"align" = @alignOf(Type) }; } - union_fields[tag_fields.len] = .{ - .name = "_padding", - .type = Padding, - .alignment = @alignOf(Padding), - }; + names[tag_fields.len] = "_padding"; + types[tag_fields.len] = options.padding; + attrs[tag_fields.len] = .{ .@"align" = @alignOf(options.padding) }; - break :cvalue @Type(.{ .@"union" = .{ - .layout = .@"extern", - .tag_type = null, - .fields = &union_fields, - .decls = &.{}, - } }); + break :cvalue @Union(.@"extern", null, &names, &types, &attrs); }; /// Convert to C union. @@ -120,17 +132,35 @@ pub fn TaggedUnion( /// Returns the value type for the given tag. pub fn Value(comptime tag: Tag) type { - @setEvalBranchQuota(10000); - inline for (@typeInfo(Union).@"union".fields) |field| { - const field_tag = @field(Tag, field.name); - if (field_tag == tag) return field.type; - } - - unreachable; + return @FieldType(Union, @tagName(tag)); } }; } +/// Options for generating the C representation of a tagged union. +pub fn TaggedUnionOptions(comptime Union: type) type { + const Tag = @typeInfo(Union).@"union".tag_type.?; + const tag_fields = @typeInfo(Tag).@"enum".fields; + const FieldRenames: type = field_renames: { + const default_rename: ?[]const u8 = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?[]const u8; + attrs[i] = .{ .default_value_ptr = &default_rename }; + } + + break :field_renames @Struct(.auto, null, &names, &types, &attrs); + }; + return struct { + padding: type, + field_renames: FieldRenames = .{}, + }; +} test "TaggedUnion: matching size" { const Tag = enum(c_int) { a, b }; const U = TaggedUnion( @@ -139,7 +169,7 @@ test "TaggedUnion: matching size" { a: u32, b: u64, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -152,7 +182,7 @@ test "TaggedUnion: padded size" { union(Tag) { a: u32, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -163,9 +193,39 @@ test "TaggedUnion: c conversion" { const U = TaggedUnion(.c, union(Tag) { a: u32, b: u64, - }, u64); + }, .{ .padding = u64 }); const c = U.cval(.{ .a = 42 }); try testing.expectEqual(Tag.a, c.tag); try testing.expectEqual(42, c.value.a); } + +test "TaggedUnion: custom C value fields" { + const Tag = enum(c_int) { a, b, none }; + const Union = union(Tag) { + a: u32, + b: u32, + none, + }; + const U = TaggedUnion(.c, Union, .{ + .padding = u64, + .field_renames = .{ + .a = "value", + .b = "value", + }, + }); + + try testing.expect(!@hasField(U.CValue, "value")); + try testing.expect(@hasField(U.CValue, "a")); + try testing.expect(@hasField(U.CValue, "b")); + try testing.expect(@hasField(U.CValue, "none")); + try testing.expectEqualStrings("value", U.C.cFieldRename(.a).?); + try testing.expect(U.C.cFieldRename(.none) == null); + + const a = U.cval(.{ .a = 42 }); + try testing.expectEqual(Tag.a, a.tag); + try testing.expectEqual(42, a.value.a); + + const none = U.cval(.none); + try testing.expectEqual(Tag.none, none.tag); +} |
