terminal: reclaim pooled and compressed page memory on Windows

This adds memory decommit/recommit support to Windows via
DiscardVirtualMemory. This allows unused page memory to be reclaimed
the same way it is already today on Linux and macOS.

DiscardVirtualMemory releases the physical pages behind a committed
range but leaves it committed, so a later access finds a zero page or
the old contents rather than faulting, and nothing has to be committed
again before reuse. That keeps recommit a no-op and, more importantly,
keeps restoring a compressed page infallible. 

Windows has an alternative `VirtualFree(MEM_DECOMMIT)` followed by 
`VirtualAlloc(MEM_COMMIT)` which releases the commit charge as well, but 
Windows has no overcommit, so the commit can be refused on restore and our
restore path doesn't support OOM.

https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-discardvirtualmemory
This commit is contained in:
Mitchell Hashimoto
2026-09-09 07:07:13 -07:00
parent 448062571c
commit 60b43068c5
3 changed files with 70 additions and 11 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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);