From f6cb8312b38088e4038296ecc5dde3f23c83fd94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Sep 2026 11:33:14 -0700 Subject: [PATCH] tinyio: implement a Windows version and use it in the C API Implements TinyIO for Windows which is used to save binary and runtime costs. As a reminder, binary costs are saved because `std.Io` uses a vtable so compilers can't prune any unused functions, so you pay for the full cost. We can noop unused functions to save. Runtime is saved because there is less state to carry for unused functionality like concurrency primitives. The impl itself is mostly taken from Zig directly. I ran tests on Windows (arm64) and verified everything works as expected so far! Binary size measurements before/after: | Mode | Io owner | ghostty-vt.dll | vs. Threaded | |--------------|-----------------|---------------:|-------------:| | ReleaseFast | std.Io.Threaded | 2,209,280 | | | ReleaseFast | std.Io.failing | 1,815,552 | -393,728 | | ReleaseFast | TinyIo | 1,826,816 | -382,464 | | ReleaseSmall | std.Io.Threaded | 1,541,632 | | | ReleaseSmall | std.Io.failing | 1,190,912 | -350,720 | | ReleaseSmall | TinyIo | 1,199,616 | -342,016 | The runtime savings are relatively small, but 1KB per terminal ain't nothing: | Io owner | Private, +100 terminals | Private, startup | |-----------------|------------------------:|-----------------:| | std.Io.Threaded | +161,845,248 | 782,336 | | TinyIo | +161,742,848 | 729,088 | --- src/lib/TinyIo.zig | 1370 +---------------------------------- src/lib/tinyio/posix.zig | 608 ++++++++++++++++ src/lib/tinyio/test.zig | 836 +++++++++++++++++++++ src/lib/tinyio/windows.zig | 745 +++++++++++++++++++ src/lib_vt.zig | 5 +- src/os/windows.zig | 99 +++ src/terminal/c/snapshot.zig | 14 +- src/terminal/c/terminal.zig | 61 +- 8 files changed, 2341 insertions(+), 1397 deletions(-) create mode 100644 src/lib/tinyio/posix.zig create mode 100644 src/lib/tinyio/test.zig create mode 100644 src/lib/tinyio/windows.zig 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); }