diff --git a/src/os/windows.zig b/src/os/windows.zig index feef8ce92..ee8c33430 100644 --- a/src/os/windows.zig +++ b/src/os/windows.zig @@ -73,6 +73,7 @@ pub const TRUE: windows.BOOL = .fromBool(true); // Bit-field and enum constant values pub const CREATE_UNICODE_ENVIRONMENT = 0x00000400; +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; @@ -213,6 +214,11 @@ pub const exp = struct { dwSize: SIZE_T, dwFreeType: DWORD, ) callconv(.winapi) BOOL; + /// https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-discardvirtualmemory + pub extern "kernel32" fn DiscardVirtualMemory( + VirtualAddress: PVOID, + Size: SIZE_T, + ) callconv(.winapi) DWORD; pub extern "kernel32" fn WaitForSingleObject( hHandle: HANDLE, dwMilliseconds: DWORD, diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 746a6cf8c..94ef9bb0f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4587,6 +4587,10 @@ inline fn createPageExt( /// Standard-sized output borrows a page-pool item so repeated compression can /// reuse the same virtual mapping. Oversized pages use a temporary allocation /// from the page allocator and release it immediately after compression. +/// +/// A borrowed item goes back to the pool through zero-mode decommit, which +/// only has to clear the bytes the encoder wrote. Callers therefore pass the +/// dirty length to `deinit` rather than paying to clear the whole item. const CompressionScratch = union(enum) { pooled: *align(std.heap.page_size_min) [std_size]u8, allocated: []align(std.heap.page_size_min) u8, @@ -4619,10 +4623,14 @@ const CompressionScratch = union(enum) { }; } - fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + fn deinit( + self: *CompressionScratch, + pool: *MemoryPool, + dirty_len: usize, + ) void { switch (self.*) { .pooled => |memory| { - _ = terminal_mem.decommit(.zero, memory, memory.len); + _ = terminal_mem.decommit(.zero, memory, dirty_len); pool.pages.destroy(memory); }, .allocated => |memory| { @@ -4942,10 +4950,15 @@ fn compressPage(self: *PageList, node: *List.Node) bool { ) catch |err| switch (err) { error.OutOfMemory => return false, }; - defer scratch.deinit(&self.pool); + + // The encoder writes at most `required` bytes and reports exactly + // how many on success. Track that so returning the scratch only + // clears the prefix it dirtied instead of the whole item. + var dirty_len: usize = required; + defer scratch.deinit(&self.pool, dirty_len); var table: compression.lz4.HashTable = undefined; - break :candidate compression.Page.init( + const result = compression.Page.init( self.pool.alloc, page, scratch.bytes()[0..required], @@ -4956,6 +4969,8 @@ fn compressPage(self: *PageList, node: *List.Node) bool { error.OutputTooSmall, => return false, }; + if (result) |compressed| dirty_len = compressed.encoded.len; + break :candidate result; }; // Null means compression crossed the break-even point. The node and its diff --git a/src/terminal/mem.zig b/src/terminal/mem.zig index 7fa314802..f1c018f82 100644 --- a/src/terminal/mem.zig +++ b/src/terminal/mem.zig @@ -4,9 +4,15 @@ //! discard the physical pages behind one of those mappings without releasing //! its virtual address range, then prepare the same range for reuse. It does //! not allocate memory or decide which terminal pages should be discarded. +//! +//! Decommit releases physical pages only. The address range and its memory +//! accounting (the Linux VMA, the Windows commit charge) stay with the +//! process, so a read after decommit returns zeros or the old contents rather +//! than faulting, and recommit has nothing to acquire that could fail. const std = @import("std"); const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; +const windows = @import("../os/windows.zig"); const log = std.log.scoped(.terminal_mem); @@ -26,9 +32,9 @@ pub const DecommitMode = enum { /// /// Test builds support both modes because `decommit` simulates reclamation by /// clearing the supplied range. Runtime reclamation is intentionally limited -/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory -/// resident; zero mode still provides its documented memset fallback through -/// `decommit` even when this function returns false. +/// to 64-bit Linux, Darwin, and Windows. Other targets must leave strict +/// callers' memory resident; zero mode still provides its documented memset +/// fallback through `decommit` even when this function returns false. pub inline fn canReclaim(comptime mode: DecommitMode) bool { // Both modes use the same retained-mapping primitives. Keeping the switch // exhaustive makes additions to DecommitMode choose target support @@ -58,6 +64,13 @@ pub inline fn canReclaim(comptime mode: DecommitMode) bool { // dependency to libghostty-vt. if (builtin.target.os.tag.isDarwin()) break :supported true; + // Windows provides DiscardVirtualMemory, which releases the + // physical pages behind a committed range while keeping it + // committed, so nothing has to be committed again before reuse. + // Page memory is already a VirtualAlloc region (see page.zig) + // and kernel32 is linked by every Windows build. + if (builtin.target.os.tag == .windows) break :supported true; + // Other targets have no retained-mapping reclamation contract in // this module. Zero mode can still clear through its memset // fallback, but strict callers must leave their mapping resident. @@ -144,16 +157,41 @@ pub fn decommit( } } + // DiscardVirtualMemory releases the physical pages behind the range but + // leaves it committed, so the commit charge stays with the process and a + // later access finds a zero page or the old contents instead of faulting. + // Zero mode clears its dirty prefix first, as on Darwin: the bytes read + // as zero afterward whether or not the discard took. Strict mode skips + // that write because its caller replaces the entire mapping after + // recommit. The call reports failure through its return value rather + // than the thread's last error. + if (comptime builtin.os.tag == .windows) { + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + + const rc = windows.exp.kernel32.DiscardVirtualMemory( + memory.ptr, + memory.len, + ); + if (rc == windows.ERROR_SUCCESS) return true; + + // Zero mode has already cleared its bytes and strict callers must + // leave the still-resident mapping alone, so there is nothing more + // to do for either mode. + log.warn("DiscardVirtualMemory failed err={d}", .{rc}); + return false; + } + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); return false; } /// Prepare a mapping previously passed to decommit for reuse. /// -/// Linux and test builds need no explicit operation. Darwin pairs -/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted -/// to the process again. Failure does not invalidate the retained mapping, so -/// reuse can continue after logging the accounting failure. +/// Linux, Windows, and test builds need no explicit operation because their +/// mappings stay committed through decommit. Darwin pairs FREE_REUSABLE with +/// FREE_REUSE so pages touched by the caller are accounted to the process +/// again. Failure does not invalidate the retained mapping, so reuse can +/// continue after logging the accounting failure. pub fn recommit(memory: []align(std.heap.page_size_min) u8) void { assert(memory.len > 0); assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0);