diff --git a/src/lib/TinyIo.zig b/src/lib/TinyIo.zig index ddabe99fd..118af1970 100644 --- a/src/lib/TinyIo.zig +++ b/src/lib/TinyIo.zig @@ -3,7 +3,9 @@ //! //! 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. +//! 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 @@ -17,23 +19,23 @@ //! concurrency of calls that those syscalls support (which is usually //! safe on POSIX systems). //! -//! A lot of the direct syscall code is cribbed from Zig's std.posix. +//! 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 posix = std.posix; const Io = std.Io; -const File = Io.File; -const Dir = Io.Dir; -const Threaded = std.Io.Threaded; /// True if this platform has a real TinyIo implementation. On /// unsupported platforms `io()` still works but every operation fails /// like `std.Io.failing`. pub const supported: bool = switch (builtin.os.tag) { - .windows, .wasi, .freestanding, .other, .uefi => false, + .wasi, .freestanding, .other, .uefi => false, else => true, }; @@ -95,12 +97,12 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .dirAccess = Io.failingDirAccess, .dirCreateFile = Io.failingDirCreateFile, .dirCreateFileAtomic = Io.failingDirCreateFileAtomic, - .dirOpenFile = dirOpenFile, - .dirClose = dirClose, + .dirOpenFile = impl.dirOpenFile, + .dirClose = impl.dirClose, .dirRead = Io.noDirRead, .dirRealPath = Io.failingDirRealPath, - .dirRealPathFile = dirRealPathFile, - .dirDeleteFile = dirDeleteFile, + .dirRealPathFile = impl.dirRealPathFile, + .dirDeleteFile = impl.dirDeleteFile, .dirDeleteDir = Io.failingDirDeleteDir, .dirRename = Io.failingDirRename, .dirRenamePreserve = Io.failingDirRenamePreserve, @@ -113,15 +115,15 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .dirSetTimestamps = Io.noDirSetTimestamps, .dirHardLink = Io.failingDirHardLink, - .fileStat = fileStat, - .fileLength = fileLength, - .fileClose = fileClose, + .fileStat = impl.fileStat, + .fileLength = impl.fileLength, + .fileClose = impl.fileClose, .fileWritePositional = Io.failingFileWritePositional, .fileWriteFileStreaming = Io.noFileWriteFileStreaming, .fileWriteFilePositional = Io.noFileWriteFilePositional, - .fileReadPositional = fileReadPositional, - .fileSeekBy = fileSeekBy, - .fileSeekTo = fileSeekTo, + .fileReadPositional = impl.fileReadPositional, + .fileSeekBy = impl.fileSeekBy, + .fileSeekTo = impl.fileSeekTo, .fileSync = Io.failingFileSync, .fileIsTty = Io.unreachableFileIsTty, .fileEnableAnsiEscapeCodes = Io.unreachableFileEnableAnsiEscapeCodes, @@ -134,7 +136,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .fileTryLock = Io.failingFileTryLock, .fileUnlock = Io.unreachableFileUnlock, .fileDowngradeLock = Io.failingFileDowngradeLock, - .fileRealPath = fileRealPath, + .fileRealPath = impl.fileRealPath, .fileHardLink = Io.failingFileHardLink, .fileMemoryMapCreate = Io.failingFileMemoryMapCreate, @@ -185,16 +187,12 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .netLookup = Io.failingNetLookup, }; -// 64-bit offset syscall selection, same as `std.Io.Threaded`. -const openat_sym = if (posix.lfs64_abi) posix.system.openat64 else posix.system.openat; -const fstat_sym = if (posix.lfs64_abi) posix.system.fstat64 else posix.system.fstat; -const fstatat_sym = if (posix.lfs64_abi) posix.system.fstatat64 else posix.system.fstatat; -const lseek_sym = if (posix.lfs64_abi) posix.system.lseek64 else posix.system.lseek; -const preadv_sym = if (posix.lfs64_abi) posix.system.preadv64 else posix.system.preadv; - -const have_preadv = switch (builtin.os.tag) { - .haiku => false, - else => true, +/// 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 {} @@ -214,240 +212,7 @@ fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void { if (buffer.len == 0) return; - - // 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; -} - -fn closeFd(fd: posix.fd_t) void { - // Never retry close on EINTR: POSIX leaves the fd state unspecified - // and Linux always closes it, so retrying risks closing an unrelated - // fd opened by another thread. - _ = posix.system.close(fd); -} - -fn dirOpenFile( - _: ?*anyopaque, - dir: Dir, - sub_path: []const u8, - options: Dir.OpenFileOptions, -) File.OpenError!File { - var path_buffer: [posix.PATH_MAX]u8 = undefined; - const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); - - // Nothing in the terminal locks files. Implementing this requires - // flock fallbacks (see std.Io.Threaded); report it as unsupported. - if (options.lock != .none) return error.FileLocksUnsupported; - - var flags: posix.O = .{ - .ACCMODE = switch (options.mode) { - .read_only => .RDONLY, - .write_only => .WRONLY, - .read_write => .RDWR, - }, - .NOFOLLOW = !options.follow_symlinks, - }; - if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; - if (@hasField(posix.O, "LARGEFILE")) flags.LARGEFILE = true; - if (@hasField(posix.O, "NOCTTY")) flags.NOCTTY = !options.allow_ctty; - if (@hasField(posix.O, "PATH")) flags.PATH = options.path_only; - if (@hasField(posix.O, "RESOLVE_BENEATH")) flags.RESOLVE_BENEATH = options.resolve_beneath; - - const mode: posix.mode_t = 0; - const fd: posix.fd_t = while (true) { - const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); - switch (posix.errno(rc)) { - .SUCCESS => break @intCast(rc), - .INTR => continue, - .INVAL => return error.BadPathName, - .ACCES => return error.AccessDenied, - .FBIG => return error.FileTooBig, - .OVERFLOW => return error.FileTooBig, - .ISDIR => return error.IsDir, - .LOOP => return error.SymLinkLoop, - .MFILE => return error.ProcessFdQuotaExceeded, - .NAMETOOLONG => return error.NameTooLong, - .NFILE => return error.SystemFdQuotaExceeded, - .NODEV => return error.NoDevice, - .NOENT => return error.FileNotFound, - .SRCH => return error.FileNotFound, - .NOMEM => return error.SystemResources, - .NOSPC => return error.NoSpaceLeft, - .NOTDIR => return error.NotDir, - .PERM => return error.PermissionDenied, - .EXIST => return error.PathAlreadyExists, - .BUSY => return error.DeviceBusy, - .OPNOTSUPP => return error.FileLocksUnsupported, - .AGAIN => return error.WouldBlock, - .TXTBSY => return error.FileBusy, - .NXIO => return error.NoDevice, - .ROFS => return error.ReadOnlyFileSystem, - .ILSEQ => return error.BadPathName, - else => |err| return posix.unexpectedErrno(err), - } - }; - errdefer closeFd(fd); - - const file: File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; - - if (!options.allow_directory) { - const is_dir = is_dir: { - const stat = fileStat(null, file) catch |err| switch (err) { - // Directory-ness is unknown or unknowable. - error.Streaming => break :is_dir false, - else => |e| return e, - }; - break :is_dir stat.kind == .directory; - }; - if (is_dir) return error.IsDir; - } - - return file; -} - -fn dirClose(_: ?*anyopaque, dirs: []const Dir) void { - for (dirs) |dir| closeFd(dir.handle); -} - -fn fileClose(_: ?*anyopaque, files: []const File) void { - for (files) |file| closeFd(file.handle); -} - -fn fileStat(_: ?*anyopaque, file: File) File.StatError!File.Stat { - if (builtin.os.tag == .linux) { - const linux = std.os.linux; - while (true) { - var statx = std.mem.zeroes(linux.Statx); - switch (linux.errno(linux.statx( - file.handle, - "", - linux.AT.EMPTY_PATH, - Threaded.linux_statx_request, - &statx, - ))) { - .SUCCESS => return Threaded.statFromLinux(&statx), - .INTR => continue, - .NOMEM => return error.SystemResources, - else => |err| return posix.unexpectedErrno(err), - } - } - } - - while (true) { - var stat = std.mem.zeroes(posix.Stat); - switch (posix.errno(fstat_sym(file.handle, &stat))) { - .SUCCESS => return Threaded.statFromPosix(&stat), - .INTR => continue, - .NOMEM => return error.SystemResources, - .ACCES => return error.AccessDenied, - else => |err| return posix.unexpectedErrno(err), - } - } -} - -fn fileLength(userdata: ?*anyopaque, file: File) File.LengthError!u64 { - const stat = try fileStat(userdata, file); - return stat.size; -} - -/// Gathers non-empty buffers into iovecs. Returns an empty slice if there -/// is nothing to read into. -fn buffersToIovecs( - data: []const []u8, - iovecs_buffer: *[Threaded.max_iovecs_len]posix.iovec, -) []posix.iovec { - var i: usize = 0; - for (data) |buf| { - if (iovecs_buffer.len - i == 0) break; - if (buf.len != 0) { - iovecs_buffer[i] = .{ .base = buf.ptr, .len = buf.len }; - i += 1; - } - } - return iovecs_buffer[0..i]; -} - -fn fileReadPositional( - _: ?*anyopaque, - file: File, - data: []const []u8, - offset: u64, -) File.ReadPositionalError!usize { - var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; - const dest = buffersToIovecs(data, &iovecs_buffer); - if (dest.len == 0) return 0; - - while (true) { - const rc = if (comptime have_preadv) - preadv_sym(file.handle, dest.ptr, @intCast(dest.len), @bitCast(offset)) - else - posix.system.pread(file.handle, dest[0].base, dest[0].len, @bitCast(offset)); - switch (posix.errno(rc)) { - .SUCCESS => return @bitCast(rc), - .INTR, .TIMEDOUT => continue, - .NXIO => return error.Unseekable, - .SPIPE => return error.Unseekable, - .OVERFLOW => return error.Unseekable, - .NOBUFS => return error.SystemResources, - .NOMEM => return error.SystemResources, - .AGAIN => return error.WouldBlock, - .IO => return error.InputOutput, - .ISDIR => return error.IsDir, - .BADF => return error.NotOpenForReading, - else => |err| return posix.unexpectedErrno(err), - } - } -} - -fn fileReadStreaming( - file: File, - data: []const []u8, -) Io.Operation.FileReadStreaming.Error!usize { - var iovecs_buffer: [Threaded.max_iovecs_len]posix.iovec = undefined; - const dest = buffersToIovecs(data, &iovecs_buffer); - if (dest.len == 0) return 0; - - while (true) { - const rc = posix.system.readv(file.handle, dest.ptr, @intCast(dest.len)); - switch (posix.errno(rc)) { - .SUCCESS => { - if (rc == 0) return error.EndOfStream; - return @intCast(rc); - }, - .INTR, .TIMEDOUT => continue, - .AGAIN => return error.WouldBlock, - .IO => return error.InputOutput, - .ISDIR => return error.IsDir, - .NOBUFS => return error.SystemResources, - .NOMEM => return error.SystemResources, - .NOTCONN => return error.SocketUnconnected, - .CONNRESET => return error.ConnectionResetByPeer, - .BADF => return error.NotOpenForReading, - else => |err| return posix.unexpectedErrno(err), - } - } + return impl.randomSecure(buffer); } fn operate( @@ -459,7 +224,7 @@ fn operate( // don't support positional reads (and the fallback path in // general). .file_read_streaming => |o| return .{ - .file_read_streaming = fileReadStreaming(o.file, o.data), + .file_read_streaming = impl.fileReadStreaming(o.file, o.data), }, // Everything else (streaming writes, ioctls, socket receives) is @@ -468,220 +233,6 @@ fn operate( } } -fn fileSeekBy(_: ?*anyopaque, file: File, offset: i64) File.SeekError!void { - while (true) { - const rc = lseek_sym(file.handle, offset, posix.SEEK.CUR); - switch (posix.errno(rc)) { - .SUCCESS => return, - .INTR => continue, - .INVAL => return error.Unseekable, - .OVERFLOW => return error.Unseekable, - .SPIPE => return error.Unseekable, - .NXIO => return error.Unseekable, - else => |err| return posix.unexpectedErrno(err), - } - } -} - -fn fileSeekTo(_: ?*anyopaque, file: File, offset: u64) File.SeekError!void { - while (true) { - const rc = lseek_sym(file.handle, @bitCast(offset), posix.SEEK.SET); - switch (posix.errno(rc)) { - .SUCCESS => return, - .INTR => continue, - .INVAL => return error.Unseekable, - .OVERFLOW => return error.Unseekable, - .SPIPE => return error.Unseekable, - .NXIO => return error.Unseekable, - else => |err| return posix.unexpectedErrno(err), - } - } -} - -fn fileRealPath( - _: ?*anyopaque, - file: File, - out_buffer: []u8, -) File.RealPathError!usize { - return realPathFd(file.handle, out_buffer); -} - -fn realPathFd(fd: posix.fd_t, out_buffer: []u8) File.RealPathError!usize { - switch (builtin.os.tag) { - .dragonfly, .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { - var sufficient_buffer: [posix.PATH_MAX]u8 = undefined; - @memset(&sufficient_buffer, 0); - while (true) { - switch (posix.errno(posix.system.fcntl(fd, posix.F.GETPATH, &sufficient_buffer))) { - .SUCCESS => break, - .INTR => continue, - .ACCES => return error.AccessDenied, - .BADF => return error.FileNotFound, - .NOENT => return error.FileNotFound, - .NOMEM => return error.SystemResources, - .NOSPC => return error.NameTooLong, - .RANGE => return error.NameTooLong, - else => |err| return posix.unexpectedErrno(err), - } - } - const n = std.mem.indexOfScalar(u8, &sufficient_buffer, 0) orelse sufficient_buffer.len; - if (n > out_buffer.len) return error.NameTooLong; - @memcpy(out_buffer[0..n], sufficient_buffer[0..n]); - return n; - }, - - .linux, .serenity, .illumos => { - var procfs_buf: ["/proc/self/path/-2147483648\x00".len]u8 = undefined; - const template = if (builtin.os.tag == .illumos) "/proc/self/path/{d}" else "/proc/self/fd/{d}"; - const proc_path = std.fmt.bufPrintSentinel(&procfs_buf, template, .{fd}, 0) catch unreachable; - while (true) { - const rc = posix.system.readlink(proc_path, out_buffer.ptr, out_buffer.len); - switch (posix.errno(rc)) { - .SUCCESS => return @bitCast(rc), - .INTR => continue, - .ACCES => return error.AccessDenied, - .IO => return error.FileSystem, - .LOOP => return error.SymLinkLoop, - .NAMETOOLONG => return error.NameTooLong, - .NOENT => return error.FileNotFound, - .NOMEM => return error.SystemResources, - .NOTDIR => return error.NotDir, - else => |err| return posix.unexpectedErrno(err), - } - } - }, - - .freebsd => { - var k_file: std.c.kinfo_file = undefined; - k_file.structsize = std.c.KINFO_FILE_SIZE; - while (true) { - switch (posix.errno(std.c.fcntl(fd, std.c.F.KINFO, @intFromPtr(&k_file)))) { - .SUCCESS => break, - .INTR => continue, - .BADF => return error.FileNotFound, - else => |err| return posix.unexpectedErrno(err), - } - } - const len = std.mem.findScalar(u8, &k_file.path, 0) orelse k_file.path.len; - if (len == 0) return error.NameTooLong; - @memcpy(out_buffer[0..len], k_file.path[0..len]); - return len; - }, - - else => return error.OperationUnsupported, - } -} - -fn dirRealPathFile( - _: ?*anyopaque, - dir: Dir, - sub_path: []const u8, - out_buffer: []u8, -) Dir.RealPathFileError!usize { - var path_buffer: [posix.PATH_MAX]u8 = undefined; - const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); - - if (builtin.link_libc and dir.handle == posix.AT.FDCWD) { - if (out_buffer.len < posix.PATH_MAX) return error.NameTooLong; - while (true) { - if (std.c.realpath(sub_path_posix, out_buffer.ptr)) |redundant_pointer| { - std.debug.assert(redundant_pointer == out_buffer.ptr); - return std.mem.indexOfScalar(u8, out_buffer, 0) orelse out_buffer.len; - } - switch (@as(posix.E, @enumFromInt(std.c._errno().*))) { - .INTR => continue, - .ACCES => return error.AccessDenied, - .NOENT => return error.FileNotFound, - .OPNOTSUPP => return error.OperationUnsupported, - .NOTDIR => return error.NotDir, - .NAMETOOLONG => return error.NameTooLong, - .LOOP => return error.SymLinkLoop, - .IO => return error.InputOutput, - else => |err| return posix.unexpectedErrno(err), - } - } - } - - // Fallback: open the path and resolve the fd. Used for non-cwd - // directory handles (which the terminal itself never passes) and - // non-libc builds. - var flags: posix.O = .{}; - if (@hasField(posix.O, "NONBLOCK")) flags.NONBLOCK = true; - if (@hasField(posix.O, "CLOEXEC")) flags.CLOEXEC = true; - if (@hasField(posix.O, "PATH")) flags.PATH = true; - - const mode: posix.mode_t = 0; - const fd: posix.fd_t = while (true) { - const rc = openat_sym(dir.handle, sub_path_posix, flags, mode); - switch (posix.errno(rc)) { - .SUCCESS => break @intCast(rc), - .INTR => continue, - .INVAL => return error.BadPathName, - .ACCES => return error.AccessDenied, - .LOOP => return error.SymLinkLoop, - .NAMETOOLONG => return error.NameTooLong, - .NOENT => return error.FileNotFound, - .NOMEM => return error.SystemResources, - .NOTDIR => return error.NotDir, - .ILSEQ => return error.BadPathName, - else => |err| return posix.unexpectedErrno(err), - } - }; - defer closeFd(fd); - - return realPathFd(fd, out_buffer); -} - -fn dirDeleteFile( - _: ?*anyopaque, - dir: Dir, - sub_path: []const u8, -) Dir.DeleteFileError!void { - var path_buffer: [posix.PATH_MAX]u8 = undefined; - const sub_path_posix = try Threaded.pathToPosix(sub_path, &path_buffer); - - while (true) { - switch (posix.errno(posix.system.unlinkat(dir.handle, sub_path_posix, 0))) { - .SUCCESS => return, - .INTR => continue, - // Some systems return EPERM when trying to delete a directory; - // stat to disambiguate from a real permission error. - .PERM => switch (builtin.os.tag) { - .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd, .illumos => { - var st = std.mem.zeroes(posix.Stat); - while (true) { - switch (posix.errno(fstatat_sym( - dir.handle, - sub_path_posix, - &st, - posix.AT.SYMLINK_NOFOLLOW, - ))) { - .SUCCESS => break, - .INTR => continue, - else => return error.PermissionDenied, - } - } - const is_dir = st.mode & posix.S.IFMT == posix.S.IFDIR; - return if (is_dir) error.IsDir else error.PermissionDenied; - }, - else => return error.PermissionDenied, - }, - .ACCES => return error.AccessDenied, - .BUSY => return error.FileBusy, - .IO => return error.FileSystem, - .ISDIR => return error.IsDir, - .LOOP => return error.SymLinkLoop, - .NAMETOOLONG => return error.NameTooLong, - .NOENT => return error.FileNotFound, - .NOTDIR => return error.NotDir, - .NOMEM => return error.SystemResources, - .ROFS => return error.ReadOnlyFileSystem, - .ILSEQ => return error.BadPathName, - else => |err| return posix.unexpectedErrno(err), - } - } -} - /// Convert an `Io.Timeout` to relative nanoseconds without consulting a /// clock. Deadlines can't be resolved without `now` support; treat them /// as a short poll, which is valid because futex waits are allowed to @@ -701,873 +252,18 @@ fn futexWait( timeout: Io.Timeout, ) Io.Cancelable!void { _ = userdata; - futexWaitInner(ptr, expected, timeoutToNs(timeout)); + impl.futexWaitInner(ptr, expected, timeoutToNs(timeout)); } fn futexWaitUncancelable(userdata: ?*anyopaque, ptr: *const u32, expected: u32) void { _ = userdata; - futexWaitInner(ptr, expected, null); -} - -fn futexWaitInner(ptr: *const u32, expected: u32, timeout_ns: ?u64) void { - @branchHint(.cold); - - if (builtin.single_threaded) unreachable; // nobody would ever wake us - - switch (builtin.os.tag) { - .linux => { - const linux = std.os.linux; - var ts_buffer: linux.timespec = undefined; - const ts: ?*linux.timespec = if (timeout_ns) |ns| ts: { - ts_buffer = .{ - .sec = @intCast(ns / std.time.ns_per_s), - .nsec = @intCast(ns % std.time.ns_per_s), - }; - break :ts &ts_buffer; - } else null; - const rc = linux.futex_4arg(ptr, .{ .cmd = .WAIT, .private = true }, expected, ts); - switch (linux.errno(rc)) { - .SUCCESS => {}, // notified by wake - .INTR => {}, // caller's responsibility to retry - .AGAIN => {}, // ptr.* != expected - .INVAL => {}, // possibly timeout overflow - .TIMEDOUT => {}, - else => {}, // spurious wakeup; caller retries - } - }, - - .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { - const c = std.c; - const flags: c.UL = .{ - .op = .COMPARE_AND_WAIT, - .NO_ERRNO = true, - }; - const us: u32 = us: { - const ns = timeout_ns orelse break :us 0; // 0 means infinite - const us = std.math.lossyCast(u32, ns / std.time.ns_per_us); - break :us if (us == 0) 1 else us; - }; - const status = c.__ulock_wait(flags, ptr, expected, us); - if (status >= 0) return; - switch (@as(c.E, @enumFromInt(-status))) { - .INTR => {}, // spurious wake - .FAULT => {}, // futex address paged out; caller retries - .TIMEDOUT => {}, - else => {}, // spurious wakeup; caller retries - } - }, - - .freebsd => { - const flags = @intFromEnum(std.c.UMTX_OP.WAIT_UINT_PRIVATE); - var tm_size: usize = 0; - var tm: std.c._umtx_time = undefined; - var tm_ptr: ?*const std.c._umtx_time = null; - if (timeout_ns) |ns| { - tm_ptr = &tm; - tm_size = @sizeOf(@TypeOf(tm)); - tm.flags = 0; // relative time - tm.clockid = .MONOTONIC; - tm.timeout = .{ - .sec = @intCast(ns / std.time.ns_per_s), - .nsec = @intCast(ns % std.time.ns_per_s), - }; - } - _ = std.c._umtx_op( - @intFromPtr(ptr), - flags, - @as(c_ulong, expected), - tm_size, - @intFromPtr(tm_ptr), - ); - }, - - else => { - // Portable fallback: futex waits may wake spuriously, so a - // bounded sleep is a valid (if inefficient) implementation. - // Contention is not expected in libghostty-vt's threading - // model, so this is effectively never reached. - if (@atomicLoad(u32, ptr, .seq_cst) != expected) return; - const ns = @min(timeout_ns orelse std.time.ns_per_ms, std.time.ns_per_ms); - const ts: posix.timespec = .{ - .sec = 0, - .nsec = @intCast(ns), - }; - _ = posix.system.nanosleep(&ts, null); - }, - } + impl.futexWaitInner(ptr, expected, null); } fn futexWake(userdata: ?*anyopaque, ptr: *const u32, max_waiters: u32) void { - @branchHint(.cold); - _ = userdata; - - if (builtin.single_threaded) return; // nothing to wake up - - switch (builtin.os.tag) { - .linux => { - const linux = std.os.linux; - _ = linux.futex_3arg( - ptr, - .{ .cmd = .WAKE, .private = true }, - @min(max_waiters, std.math.maxInt(i32)), - ); - }, - - .driverkit, .ios, .maccatalyst, .macos, .tvos, .visionos, .watchos => { - const c = std.c; - const flags: c.UL = .{ - .op = .COMPARE_AND_WAIT, - .NO_ERRNO = true, - .WAKE_ALL = max_waiters > 1, - }; - while (true) { - const status = c.__ulock_wake(flags, ptr, 0); - if (status >= 0) return; - switch (@as(c.E, @enumFromInt(-status))) { - .INTR, .CANCELED => continue, // spurious wake - else => return, - } - } - }, - - .freebsd => { - _ = std.c._umtx_op( - @intFromPtr(ptr), - @intFromEnum(std.c.UMTX_OP.WAKE_PRIVATE), - @min(max_waiters, std.math.maxInt(c_ulong)), - 0, - 0, - ); - }, - - // Portable fallback waiters poll with a timeout; nothing to do. - else => {}, - } + return impl.futexWake(userdata, ptr, max_waiters); } -test "read a file through File.Reader" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - const contents = "hello minimal test_io\n" ** 100; - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "test.txt", - .data = contents, - }); - - // Open through our Io. The Dir handle is a plain fd, so it is usable - // across Io implementations. - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "test.txt", .{}); - defer file.close(test_io); - - // Stat through our Io. - const stat = try file.stat(test_io); - try testing.expectEqual(@as(u64, contents.len), stat.size); - try testing.expectEqual(File.Kind.file, stat.kind); - - // fileLength. - try testing.expectEqual(@as(u64, contents.len), try file.length(test_io)); - - // Read it all back through File.Reader (exercises positional reads - // and streaming fallbacks). - var buf: [64]u8 = undefined; - var reader = file.reader(test_io, &buf); - var list: std.ArrayList(u8) = .empty; - defer list.deinit(testing.allocator); - try reader.interface.appendRemaining(testing.allocator, &list, .unlimited); - try testing.expectEqualStrings(contents, list.items); -} - -test "seek" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "seek.txt", - .data = "0123456789", - }); - - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "seek.txt", .{}); - defer file.close(test_io); - - // Exercise our seek and streaming read implementations directly at - // the vtable level; the higher-level File.Reader seek plumbing has - // its own buffering behaviors that are independent of the Io - // implementation. - var out: [4]u8 = undefined; - var slices = [_][]u8{&out}; - - try test_io.vtable.fileSeekTo(test_io.userdata, file, 6); - var n = try file.readStreaming(test_io, &slices); - try testing.expectEqualStrings("6789", out[0..n]); - - // Seek back relative and re-read. - try test_io.vtable.fileSeekBy(test_io.userdata, file, -8); - n = try file.readStreaming(test_io, &slices); - try testing.expectEqualStrings("2345", out[0..n]); - - // Positional reads are independent of the seek position. - var pslices = [_][]u8{&out}; - n = try test_io.vtable.fileReadPositional(test_io.userdata, file, &pslices, 1); - try testing.expectEqualStrings("1234", out[0..n]); -} - -test "realPath and deleteFile" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - // Only platforms with a real implementation. - switch (builtin.os.tag) { - .macos, .ios, .linux, .freebsd => {}, - else => return error.SkipZigTest, - } - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "real.txt", - .data = "x", - }); - - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "real.txt", .{}); - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = path_buf[0..try file.realPath(test_io, &path_buf)]; - try testing.expect(std.mem.endsWith(u8, path, "real.txt")); - file.close(test_io); - - // dirRealPathFile via absolute path from cwd. - var path_buf2: [std.fs.max_path_bytes]u8 = undefined; - const path2 = path_buf2[0..try Dir.cwd().realPathFile(test_io, path, &path_buf2)]; - try testing.expectEqualStrings(path, path2); - - // Delete it through our Io, verify it is gone. - try dir.deleteFile(test_io, "real.txt"); - try testing.expectError(error.FileNotFound, dir.openFile(test_io, "real.txt", .{})); -} - -test "openFile of a directory returns IsDir" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - try tmp_dir.dir.createDir(testing.io, "sub", .default_dir); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - try testing.expectError(error.IsDir, dir.openFile(test_io, "sub", .{ - .allow_directory = false, - })); -} - -test "Io.Mutex through TinyIo" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - // Contended and uncontended lock/unlock; exercises the futex ops. - var mutex: Io.Mutex = .init; - mutex.lockUncancelable(test_io); - mutex.unlock(test_io); - - // Wake with no waiters must be a no-op. - var word: u32 = 0; - test_io.vtable.futexWake(test_io.userdata, &word, 1); - - // Wait with a non-matching expected value must return immediately. - test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); -} - -test "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 POSIX path. - try testing.expectError(error.BadPathName, dir.openFile( - test_io, - "bad\x00path", - .{}, - )); - - // Paths that can't fit in PATH_MAX must not be silently truncated. - const long_name = "a" ** (std.fs.max_path_bytes + 1); - try testing.expectError(error.NameTooLong, dir.openFile( - test_io, - long_name, - .{}, - )); - - // A path component that is a file, not a directory. - try testing.expectError(error.NotDir, dir.openFile( - test_io, - "edge.txt/child", - .{}, - )); - - // Directories may be opened when allowed (the default) and stat - // reports their kind. - try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); - var dir_file = try dir.openFile(test_io, "subdir", .{}); - const dir_stat = try dir_file.stat(test_io); - try testing.expectEqual(File.Kind.directory, dir_stat.kind); - dir_file.close(test_io); - - // Write modes translate to the right ACCMODE flags; TinyIo can't - // write but opening for write must succeed. - var wfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .write_only }); - wfile.close(test_io); - var rwfile = try dir.openFile(test_io, "edge.txt", .{ .mode = .read_write }); - rwfile.close(test_io); -} - -test "openFile symlink handling" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - // Platforms where we know both symlink creation (via the testing Io) - // and O_NOFOLLOW behave as expected. - switch (builtin.os.tag) { - .macos, .ios, .linux, .freebsd => {}, - else => return error.SkipZigTest, - } - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "target.txt", - .data = "target", - }); - try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - - // Following symlinks (the default) opens the target... - var file = try dir.openFile(test_io, "link.txt", .{}); - try testing.expectEqual(@as(u64, "target".len), try file.length(test_io)); - - // ...and realPath resolves through the link to the target. This is - // the property the Kitty graphics path validation relies on. - var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = path_buf[0..try file.realPath(test_io, &path_buf)]; - try testing.expect(std.mem.endsWith(u8, path, "target.txt")); - file.close(test_io); - - // Refusing to follow symlinks fails with SymLinkLoop. - try testing.expectError(error.SymLinkLoop, dir.openFile( - test_io, - "link.txt", - .{ .follow_symlinks = false }, - )); -} - -test "positional reads at and beyond EOF return zero" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "eof.txt", - .data = "0123456789", - }); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "eof.txt", .{}); - defer file.close(test_io); - - var out: [4]u8 = undefined; - var slices = [_][]u8{&out}; - - // At EOF and past EOF: the vtable contract is "returns 0 if reading - // at or past the end". - try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &slices, - 10, - )); - try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &slices, - 9999, - )); - - // No buffers and only-empty buffers read nothing without a syscall. - try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &.{}, - 0, - )); - var empty = [_][]u8{ &.{}, &.{} }; - try testing.expectEqual(@as(usize, 0), try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &empty, - 0, - )); -} - -test "vectored reads scatter across buffers" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "vec.txt", - .data = "0123456789abcdef", - }); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "vec.txt", .{}); - defer file.close(test_io); - - // Scatter a positional read across multiple buffers, with empty - // buffers interleaved (they must be skipped). - var a: [4]u8 = undefined; - var b: [2]u8 = undefined; - var c: [6]u8 = undefined; - var slices = [_][]u8{ &a, &.{}, &b, &c }; - const n = try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &slices, - 0, - ); - try testing.expectEqual(@as(usize, 12), n); - try testing.expectEqualStrings("0123", &a); - try testing.expectEqualStrings("45", &b); - try testing.expectEqualStrings("6789ab", &c); - - // More buffers than max_iovecs_len: reads are truncated to the - // first max_iovecs_len non-empty buffers (partial reads are allowed - // by the vtable contract; callers retry). - comptime std.debug.assert(Threaded.max_iovecs_len < 16); - var bytes: [16][1]u8 = undefined; - var many: [16][]u8 = undefined; - for (&many, &bytes) |*s, *byte| s.* = byte; - const n2 = try test_io.vtable.fileReadPositional( - test_io.userdata, - file, - &many, - 0, - ); - try testing.expectEqual(@as(usize, Threaded.max_iovecs_len), n2); - for (bytes[0..n2], "0123456789abcdef"[0..n2]) |got, want| { - try testing.expectEqual(want, got[0]); - } -} - -test "streaming reads: EndOfStream and scatter" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "stream.txt", - .data = "streaming!", - }); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "stream.txt", .{}); - defer file.close(test_io); - - // Scatter a streaming read. - var a: [6]u8 = undefined; - var b: [4]u8 = undefined; - var slices = [_][]u8{ &a, &b }; - try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); - try testing.expectEqualStrings("stream", &a); - try testing.expectEqualStrings("ing!", &b); - - // Reading again at EOF is a stream end, not a zero-length success. - try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); - - // Empty destinations read nothing. - try testing.expectEqual(@as(usize, 0), try file.readStreaming(test_io, &.{})); - - // Seeking beyond EOF is legal; the next streaming read hits EOF. - try test_io.vtable.fileSeekTo(test_io.userdata, file, 9999); - try testing.expectError(error.EndOfStream, file.readStreaming(test_io, &slices)); - - // And seeking back to zero re-reads from the start. - try test_io.vtable.fileSeekTo(test_io.userdata, file, 0); - try testing.expectEqual(@as(usize, 10), try file.readStreaming(test_io, &slices)); - try testing.expectEqualStrings("stream", &a); -} - -test "pipes: streaming works, positional and seek are Unseekable" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var fds: [2]posix.fd_t = undefined; - switch (posix.errno(posix.system.pipe(&fds))) { - .SUCCESS => {}, - else => return error.SkipZigTest, - } - defer closeFd(fds[1]); - const read_end: File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } }; - defer read_end.close(test_io); - - const msg = "through the pipe"; - try testing.expectEqual( - @as(isize, msg.len), - @as(isize, @intCast(posix.system.write(fds[1], msg, msg.len))), - ); - - // Streaming reads work on unseekable files; this is the fallback - // File.Reader depends on when positional reads report Unseekable. - var buf: [msg.len]u8 = undefined; - var slices = [_][]u8{&buf}; - try testing.expectEqual(@as(usize, msg.len), try read_end.readStreaming(test_io, &slices)); - try testing.expectEqualStrings(msg, &buf); - - // Positional reads and seeks must report Unseekable so callers can - // fall back to streaming. - try testing.expectError(error.Unseekable, test_io.vtable.fileReadPositional( - test_io.userdata, - read_end, - &slices, - 0, - )); - try testing.expectError(error.Unseekable, test_io.vtable.fileSeekTo( - test_io.userdata, - read_end, - 0, - )); - try testing.expectError(error.Unseekable, test_io.vtable.fileSeekBy( - test_io.userdata, - read_end, - 1, - )); -} - -test "operate delegates non-read operations to failing stubs" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "w.txt", - .data = "x", - }); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - var file = try dir.openFile(test_io, "w.txt", .{ .mode = .write_only }); - defer file.close(test_io); - - const result = try test_io.vtable.operate(test_io.userdata, .{ - .file_write_streaming = .{ - .file = file, - .data = &.{"nope"}, - }, - }); - try testing.expectError(error.InputOutput, result.file_write_streaming); -} - -test "dirRealPathFile edge cases" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - switch (builtin.os.tag) { - .macos, .ios, .linux, .freebsd => {}, - else => return error.SkipZigTest, - } - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "real.txt", - .data = "x", - }); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - - // Resolve the canonical path through an open file for reference. - var file = try dir.openFile(test_io, "real.txt", .{}); - var want_buf: [std.fs.max_path_bytes]u8 = undefined; - const want = want_buf[0..try file.realPath(test_io, &want_buf)]; - file.close(test_io); - - // A non-cwd directory handle exercises the open-then-resolve - // fallback branch rather than libc realpath. - var got_buf: [std.fs.max_path_bytes]u8 = undefined; - const got = got_buf[0..try test_io.vtable.dirRealPathFile( - test_io.userdata, - dir, - "real.txt", - &got_buf, - )]; - try testing.expectEqualStrings(want, got); - - // Missing paths report FileNotFound (libc realpath branch, via an - // absolute path anchored at cwd). - var missing_buf: [std.fs.max_path_bytes]u8 = undefined; - const missing = std.fmt.bufPrint(&missing_buf, "{s}.missing", .{want}) catch - return error.SkipZigTest; - var out_buf: [std.fs.max_path_bytes]u8 = undefined; - try testing.expectError(error.FileNotFound, Dir.cwd().realPathFile( - test_io, - missing, - &out_buf, - )); - - // libc realpath requires a PATH_MAX-sized output buffer; smaller - // buffers must error rather than risk truncation. - var small_buf: [8]u8 = undefined; - try testing.expectError(error.NameTooLong, Dir.cwd().realPathFile( - test_io, - want, - &small_buf, - )); -} - -test "deleteFile edge cases" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - const dir: Dir = .{ .handle = tmp_dir.dir.handle }; - - // Nonexistent files. - try testing.expectError(error.FileNotFound, dir.deleteFile(test_io, "missing.txt")); - - // NUL bytes never form a valid POSIX path. - try testing.expectError(error.BadPathName, dir.deleteFile(test_io, "bad\x00path")); - - // Deleting a directory reports IsDir. On BSD-derived systems - // (including macOS) unlink returns EPERM for directories, which - // exercises the stat-based disambiguation path. - try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); - try testing.expectError(error.IsDir, dir.deleteFile(test_io, "subdir")); - - // Deleting a symlink removes the link, not its target. - try tmp_dir.dir.writeFile(testing.io, .{ - .sub_path = "target.txt", - .data = "x", - }); - try tmp_dir.dir.symLink(testing.io, "target.txt", "link.txt", .{}); - try dir.deleteFile(test_io, "link.txt"); - var file = try dir.openFile(test_io, "target.txt", .{}); - file.close(test_io); - try testing.expectError(error.FileNotFound, dir.openFile(test_io, "link.txt", .{})); -} - -test "dirClose closes the descriptor" { - if (comptime !supported) return error.SkipZigTest; - const tio: TinyIo = .init; - const test_io = tio.io(); - const testing = std.testing; - - var tmp_dir = testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - try tmp_dir.dir.createDir(testing.io, "subdir", .default_dir); - const opened = try tmp_dir.dir.openDir(testing.io, "subdir", .{}); - - const dir: Dir = .{ .handle = opened.handle }; - test_io.vtable.dirClose(test_io.userdata, &.{dir}); - - // Verify with a raw 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); +test { + _ = @import("tinyio/test.zig"); } diff --git a/src/lib/tinyio/posix.zig b/src/lib/tinyio/posix.zig new file mode 100644 index 000000000..10b962105 --- /dev/null +++ b/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/src/lib/tinyio/test.zig b/src/lib/tinyio/test.zig new file mode 100644 index 000000000..c6b4d96f5 --- /dev/null +++ b/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/src/lib/tinyio/windows.zig b/src/lib/tinyio/windows.zig new file mode 100644 index 000000000..a815d266d --- /dev/null +++ b/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/src/lib_vt.zig b/src/lib_vt.zig index 162798894..a0227aef7 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -45,8 +45,9 @@ pub const sys = terminal.sys; /// don't have their own `Io` can use `TinyIo` (e.g. /// `(TinyIo.init).io()`) instead of `std.Io.Threaded` to avoid linking /// Threaded's full vtable (networking, process spawning, async -/// machinery, etc.), which is worth roughly 110KB of binary size. See -/// the TinyIo docs for the exact tradeoffs. +/// machinery, etc.), which is worth roughly 110KB of binary size on +/// macOS and 370KB on Windows. See the TinyIo docs for the exact +/// tradeoffs. pub const TinyIo = @import("lib/TinyIo.zig"); pub const apc = terminal.apc; diff --git a/src/os/windows.zig b/src/os/windows.zig index ee8c33430..30b011a2b 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -73,13 +73,24 @@ pub const TRUE: windows.BOOL = .fromBool(true); // Bit-field and enum constant values pub const CREATE_UNICODE_ENVIRONMENT = 0x00000400; +pub const DELETE = 0x00010000; pub const ERROR_SUCCESS = 0; pub const EXTENDED_STARTUPINFO_PRESENT = 0x00080000; pub const FILE_ATTRIBUTE_NORMAL = 0x80; pub const FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000; pub const FILE_FLAG_OVERLAPPED = 0x40000000; +/// GetFinalPathNameByHandleW dwFlags: normalized name with a DOS drive +/// letter (both are the zero flag values). +pub const FILE_NAME_NORMALIZED = 0x0; pub const FILE_NON_DIRECTORY_FILE = 0x00000040; +/// NtCreateFile CreateDisposition: open an existing file, never create. +/// Distinct from the Win32 CreateFileW value OPEN_EXISTING. +pub const FILE_OPEN = 0x00000001; +pub const FILE_OPEN_REPARSE_POINT = 0x00200000; +pub const FILE_SHARE_DELETE = 0x00000004; pub const FILE_SHARE_READ = 0x00000001; +pub const FILE_SHARE_WRITE = 0x00000002; +pub const FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020; pub const GENERIC_READ = 0x80000000; pub const HANDLE_FLAG_INHERIT = 0x00000001; pub const MEM_COMMIT = 0x1000; @@ -94,8 +105,14 @@ pub const PROC_THREAD_ATTRIBUTE_INPUT = 0x00020000; pub const PROC_THREAD_ATTRIBUTE_NUMBER = 0x0000FFFF; pub const PROC_THREAD_ATTRIBUTE_THREAD = 0x00010000; pub const S_OK = 0; +pub const SYNCHRONIZE = 0x00100000; +pub const VOLUME_NAME_DOS = 0x0; pub const WAIT_FAILED = 0xFFFFFFFF; +/// IOCTL that fills the output buffer with bytes from the kernel CSPRNG +/// behind `\Device\CNG` (what ProcessPrng and BCryptGenRandom draw from). +pub const IOCTL_KSEC_GEN_RANDOM: CTL_CODE = windows.IOCTL.KSEC.GEN_RANDOM; + pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = ProcThreadAttributeValue( .ProcThreadAttributePseudoConsole, false, @@ -105,6 +122,14 @@ pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = ProcThreadAttributeValue( // Types needed for ntdll calls pub const ACCESS_MASK = windows.ACCESS_MASK; +pub const CTL_CODE = windows.CTL_CODE; +pub const FILE_ALL_INFORMATION = windows.FILE.ALL_INFORMATION; +pub const FILE_ATTRIBUTE_TAG_INFORMATION = windows.FILE.ATTRIBUTE_TAG_INFO; +pub const FILE_DISPOSITION_INFORMATION = windows.FILE.DISPOSITION.INFORMATION; +pub const FILE_DISPOSITION_INFORMATION_EX = windows.FILE.DISPOSITION.INFORMATION.EX; +pub const FILE_INFORMATION_CLASS = windows.FILE.INFORMATION_CLASS; +pub const FILE_POSITION_INFORMATION = windows.FILE.POSITION_INFORMATION; +pub const FILE_STANDARD_INFORMATION = windows.FILE.STANDARD_INFORMATION; pub const IO_STATUS_BLOCK = windows.IO_STATUS_BLOCK; pub const NTSTATUS = windows.NTSTATUS; pub const OBJECT_ATTRIBUTES = windows.OBJECT.ATTRIBUTES; @@ -242,6 +267,13 @@ pub const exp = struct { lpNumberOfBytesRead: ?*DWORD, lpOverlapped: ?*OVERLAPPED, ) callconv(.winapi) BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlew + pub extern "kernel32" fn GetFinalPathNameByHandleW( + hFile: HANDLE, + lpszFilePath: [*]u16, + cchFilePath: DWORD, + dwFlags: DWORD, + ) callconv(.winapi) DWORD; }; pub const ntdll = struct { pub extern "ntdll" fn NtCreateFile( @@ -257,6 +289,73 @@ pub const exp = struct { EaBuffer: ?*anyopaque, EaLength: ULONG, ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtOpenFile( + FileHandle: *HANDLE, + DesiredAccess: ACCESS_MASK, + ObjectAttributes: *OBJECT_ATTRIBUTES, + IoStatusBlock: *IO_STATUS_BLOCK, + ShareAccess: ULONG, + OpenOptions: ULONG, + ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtClose(Handle: HANDLE) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtReadFile( + FileHandle: HANDLE, + Event: ?HANDLE, + ApcRoutine: ?*const anyopaque, + ApcContext: ?*anyopaque, + IoStatusBlock: *IO_STATUS_BLOCK, + Buffer: *anyopaque, + Length: ULONG, + ByteOffset: ?*const LARGE_INTEGER, + Key: ?*const ULONG, + ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtQueryInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *IO_STATUS_BLOCK, + FileInformation: *anyopaque, + Length: ULONG, + FileInformationClass: FILE_INFORMATION_CLASS, + ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtSetInformationFile( + FileHandle: HANDLE, + IoStatusBlock: *IO_STATUS_BLOCK, + FileInformation: *anyopaque, + Length: ULONG, + FileInformationClass: FILE_INFORMATION_CLASS, + ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn NtDeviceIoControlFile( + FileHandle: HANDLE, + Event: ?HANDLE, + ApcRoutine: ?*const anyopaque, + ApcContext: ?*anyopaque, + IoStatusBlock: *IO_STATUS_BLOCK, + IoControlCode: CTL_CODE, + InputBuffer: ?*const anyopaque, + InputBufferLength: ULONG, + OutputBuffer: ?*anyopaque, + OutputBufferLength: ULONG, + ) callconv(.winapi) NTSTATUS; + /// Resolves a Win32 path against the process working directory and + /// canonicalizes it, keeping any `\\?\` or `\\.\` prefix. Returns + /// the byte length written excluding the terminator, the required + /// byte length if the buffer is too small, or 0 on failure. + pub extern "ntdll" fn RtlGetFullPathName_U( + FileName: [*:0]const u16, + BufferByteLength: ULONG, + Buffer: [*]u16, + ShortName: ?*[*:0]const u16, + ) callconv(.winapi) ULONG; + /// The futex primitives that kernel32's WaitOnAddress and + /// WakeByAddress* forward to. Timeout is a relative (negative) + /// interval in 100ns units, null to wait forever. + pub extern "ntdll" fn RtlWaitOnAddress( + Address: *const anyopaque, + CompareAddress: *const anyopaque, + AddressSize: SIZE_T, + Timeout: ?*const LARGE_INTEGER, + ) callconv(.winapi) NTSTATUS; + pub extern "ntdll" fn RtlWakeAddressSingle(Address: *const anyopaque) callconv(.winapi) void; + pub extern "ntdll" fn RtlWakeAddressAll(Address: *const anyopaque) callconv(.winapi) void; }; }; diff --git a/src/terminal/c/snapshot.zig b/src/terminal/c/snapshot.zig index 75cbc1773..13d5f2b9e 100644 --- a/src/terminal/c/snapshot.zig +++ b/src/terminal/c/snapshot.zig @@ -447,20 +447,14 @@ const ReadyTerminal = struct { metadata: DecoderWrapper.Metadata, }; -/// Decode READY, create terminal-owned I/O, and construct the C terminal. +/// Decode READY with terminal-owned I/O and construct the C terminal. fn decoderReadyTerminal(decoder: *DecoderWrapper) anyerror!ReadyTerminal { - // Terminal I/O is intentionally allocated only when decoding begins. - const io = try terminal_c.Io.init(decoder.alloc); - - // Decode READY while the fresh I/O implementation is still locally owned. - var decoded = decoder.decoder.ready( + const io: terminal_c.Io = .init; + var decoded = try decoder.decoder.ready( decoder.alloc, io.io(), .{ .max_continuation_bytes = decoder.max_continuation_bytes }, - ) catch |err| { - io.deinit(decoder.alloc); - return err; - }; + ); defer decoded.deinit(decoder.alloc); // Copy small query metadata before `fromDecoded` consumes the core result. diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 04c3e242b..736d0ca08 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -49,51 +49,21 @@ pub const default_continuation_max_bytes: usize = 0; /// /// Snapshot decoding creates this before the native terminal exists and /// transfers it into the final C wrapper after READY. +/// +/// This is TinyIo on every target: it is stateless and supports exactly +/// the operations the terminal needs at a fraction of the code size of +/// `std.Io.Threaded` (see lib/TinyIo.zig), on POSIX and Windows alike. +/// On the remaining targets (e.g. freestanding wasm) TinyIo degrades to +/// `std.Io.failing`, which is correct: they have no filesystem. pub const Io = struct { - impl: Impl, + impl: lib.TinyIo, - /// Platform-specific storage backing the public `std.Io` value. - /// - /// Where supported (POSIX) we use TinyIo, which is stateless and - /// supports exactly the operations the terminal needs at a fraction - /// of the code size (see lib/TinyIo.zig). On Windows we use - /// `std.Io.Threaded` since TinyIo doesn't implement the NT - /// operations. On the remaining targets (e.g. freestanding wasm) - /// TinyIo degrades to `std.Io.failing`, which is correct: they have - /// no filesystem. - const Impl = if (builtin.os.tag == .windows) - *std.Io.Threaded - else - lib.TinyIo; - - /// Allocation failures possible while constructing an I/O owner. - pub const Error = error{OutOfMemory}; - - /// Allocate the native I/O implementation when the platform requires it. - pub fn init(alloc: std.mem.Allocator) Error!Io { - if (comptime Impl == lib.TinyIo) return .{ .impl = .init }; - - const ptr = alloc.create(std.Io.Threaded) catch - return error.OutOfMemory; - ptr.* = .init_single_threaded; - return .{ .impl = ptr }; - } + pub const init: Io = .{ .impl = .init }; /// Return the value passed to native terminal construction and decoding. pub fn io(self: Io) std.Io { return self.impl.io(); } - - /// Release an I/O implementation that has not already been transferred. - pub fn deinit(self: Io, alloc: std.mem.Allocator) void { - // Note: this must not name `std.Io.Threaded` in the condition - // because resolving that type trips its container-level comptime - // checks on targets it doesn't support (e.g. wasm32-freestanding). - if (comptime Impl != lib.TinyIo) { - self.impl.deinit(); - alloc.destroy(self.impl); - } - } }; /// Wrapper around ZigTerminal that tracks additional state for C API usage, @@ -103,7 +73,6 @@ const TerminalWrapper = struct { terminal: *ZigTerminal, /// C construction has no I/O argument, so the wrapper retains the owner /// created by `new` or transferred from snapshot decoding until `free`. - /// Freestanding owners contain no native allocation and expose failing I/O. io: Io, /// Allocator-owned copy of the temporary directory path for some /// operations (e.g. kitty graphics). This is only allocated once the @@ -760,8 +729,9 @@ pub const FromDecodedError = error{ /// Transfer a core snapshot result into a caller-owned C terminal. /// -/// This function consumes `io` on every path. The decoded terminal is -/// transferred only after its final heap address has been allocated; its +/// `io` is the owner the decoded terminal was built with and is retained +/// by the returned wrapper. The decoded terminal is transferred only +/// after its final heap address has been allocated; its /// continuation remains in `decoded` and is replayed before returning. /// `continuation_max_bytes` selects the returned terminal's tracking policy: /// zero uses a temporary exact-size tracker and restores the ordinary C @@ -773,10 +743,8 @@ pub fn fromDecoded( decoded: *snapshot_core.Decoded, continuation_max_bytes: usize, ) FromDecodedError!Terminal { - const native = alloc.create(ZigTerminal) catch { - io.deinit(alloc); + const native = alloc.create(ZigTerminal) catch return error.OutOfMemory; - }; native.* = decoded.toOwned(); const continuation = switch (decoded.continuation) { @@ -797,7 +765,6 @@ pub fn fromDecoded( const terminal = wrap(alloc, native, io, tracker_max_bytes) catch |err| { native.deinit(alloc); alloc.destroy(native); - io.deinit(alloc); return err; }; errdefer free(terminal); @@ -858,8 +825,7 @@ fn new_( return error.OutOfMemory; errdefer alloc.destroy(t); - const io = try Io.init(alloc); - errdefer io.deinit(alloc); + const io: Io = .init; // Setup our terminal t.* = try .init( @@ -1859,7 +1825,6 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { wrapper.stream.deinit(); t.deinit(alloc); if (wrapper.tmp_dir_path) |path| alloc.free(path); - wrapper.io.deinit(alloc); alloc.destroy(t); alloc.destroy(wrapper); }