diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f26748c35..338aad4c0 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -480,7 +480,9 @@ fn initPages( const page_buf = if (pooled) buf: { try tw.check(.page_buf_std); - break :buf try pool.pages.create(); + const buf = try pool.pages.create(); + recommitPoolItem(buf); + break :buf buf; } else buf: { try tw.check(.page_buf_non_std); break :buf try page_alloc.alignedAlloc( @@ -3483,14 +3485,15 @@ inline fn createPageExt( // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. - const page_buf = if (pooled) - try pool.pages.create() - else - try page_alloc.alignedAlloc( - u8, - .fromByteUnits(std.heap.page_size_min), - layout.total_size, - ); + const page_buf = if (pooled) buf: { + const buf = try pool.pages.create(); + recommitPoolItem(buf); + break :buf buf; + } else try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + layout.total_size, + ); errdefer if (pooled) pool.pages.destroy(page_buf) else @@ -3558,9 +3561,12 @@ fn destroyNodeExt( .pool => { assert(page.memory.len <= std_size); - // Reset the memory to zero so it can be reused - @memset(page.memory, 0); - pool.pages.destroy(@ptrCast(page.memory.ptr)); + // Reset the memory to zero (and decommit it, where + // supported) so it can be reused. + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + decommitPoolItem(item, page.memory.len); + pool.pages.destroy(item); }, .heap => { @@ -3572,6 +3578,100 @@ fn destroyNodeExt( pool.nodes.destroy(node); } +/// Zero a pool item buffer that is about to be returned to the pool +/// free list and, where supported, tell the OS it can reclaim the +/// backing memory while the buffer sits unused. Without the decommit, +/// a pool that shrinks (scrollback clears, pruning churn, resets) +/// retains its high-water RSS forever, since MemoryPool free lists +/// never return memory to the OS. +/// +/// dirty_len is the length of the page that lived in this item, which +/// may be smaller than the item; bytes beyond it are already zero. +/// +/// The invariant required for page reuse (see createPageExt): after +/// this call the entire item reads back as zeroes. The pool writes its +/// free list node into the first pointer-size bytes afterwards, which +/// is safe because page initialization always overwrites them (see the +/// comptime assert in Page). +fn decommitPoolItem( + item: *align(std.heap.page_size_min) [std_size]u8, + dirty_len: usize, +) void { + // In test builds the buffer comes from std.testing.allocator, not + // the OS page allocator, so madvise is neither safe nor meaningful. + if (comptime builtin.is_test) { + @memset(item[0..dirty_len], 0); + return; + } + + // MADV_DONTNEED on a private anonymous mapping immediately + // reclaims the pages and guarantees they read back zero-filled + // on the next access, so we can skip the memset entirely. + // + // We use MADV_DONTNEED rather than MADV_FREE deliberately: + // MADV_FREE doesn't reduce RSS until memory pressure (invisible + // to users watching memory usage) and doesn't guarantee zeroes + // on the next read. MADV_DONTNEED's synchronous TLB-shootdown + // cost only matters at allocator-level call frequency; page + // destroys here are rare (clears, reflow, capacity changes), and + // the hottest reuse path (grow's prune-reuse) doesn't go through + // this function at all. + if (comptime builtin.os.tag == .linux) { + if (std.posix.madvise( + item, + std_size, + std.posix.MADV.DONTNEED, + )) |_| return else |err| { + log.warn("madvise(DONTNEED) failed err={}", .{err}); + // Fall through to the memset below. + } + } + + // On Darwin we zero in place and then mark the memory as + // reusable, which removes it from the process footprint until + // it is touched again. Contents of reusable memory are either + // preserved (our zeroes) or reclaimed and zero-filled on the + // next access, so the invariant holds either way. The reuse + // side calls MADV_FREE_REUSE to fix up footprint accounting + // (see recommitPoolItem). + if (comptime builtin.os.tag.isDarwin()) { + @memset(item[0..dirty_len], 0); + std.posix.madvise( + item, + std_size, + std.posix.MADV.FREE_REUSABLE, + ) catch { + // Best-effort: plain MADV_FREE reclaims under memory + // pressure only and needs no reuse pairing. + std.posix.madvise( + item, + std_size, + std.posix.MADV.FREE, + ) catch {}; + }; + return; + } + + @memset(item[0..dirty_len], 0); +} + +/// The counterpart to decommitPoolItem for buffers handed back out by +/// the pool. Only Darwin requires this: MADV_FREE_REUSE re-accounts +/// previously reusable memory to the process footprint. Without it the +/// memory still works (touching reusable pages revives them) but +/// footprint reporting can undercount. Harmless on buffers that were +/// never marked reusable (fresh or preheated items). +fn recommitPoolItem(item: *align(std.heap.page_size_min) [std_size]u8) void { + if (comptime builtin.is_test) return; + if (comptime builtin.os.tag.isDarwin()) { + std.posix.madvise( + item, + std_size, + std.posix.MADV.FREE_REUSE, + ) catch {}; + } +} + /// Fast-path function to erase exactly 1 row. Erasing means that the row /// is completely REMOVED, not just cleared. All rows following the removed /// row will be shifted up by 1 to fill the empty space. @@ -14344,6 +14444,66 @@ test "PageList compact oversized page" { } } +test "PageList decommitPoolItem zeroes the dirty region" { + const testing = std.testing; + const alloc = testing.allocator; + + // Note: in test builds decommitPoolItem always takes the memset + // path; the madvise-based paths rely on kernel zero-fill semantics + // that unit tests cannot exercise (see decommitPoolItem). + const buf = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + std_size, + ); + defer alloc.free(buf); + const item: *align(std.heap.page_size_min) [std_size]u8 = @ptrCast(buf.ptr); + + // Fully dirty item. + @memset(item, 0xAA); + decommitPoolItem(item, std_size); + try testing.expect(std.mem.allEqual(u8, item, 0)); + + // Partially dirty item: an item that hosted a page smaller than + // the item is only dirty up to the page length; the rest is zero + // by invariant and must remain zero. + @memset(item[0..1024], 0xAA); + decommitPoolItem(item, 1024); + try testing.expect(std.mem.allEqual(u8, item, 0)); +} + +test "PageList destroyed pool page reuse is zeroed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a page and scribble over its entire backing memory, + // then destroy it so the buffer returns to the pool free list. + const node = try s.createPage(.{ .cap = initialCapacity(80) }); + node.data.size.rows = 1; + const mem_ptr = node.data.memory.ptr; + @memset(node.data.memory, 0xAA); + s.destroyNode(node); + + // Reusing the buffer must produce a fully valid, zeroed page. + const node2 = try s.createPage(.{ .cap = initialCapacity(80) }); + try testing.expectEqual(mem_ptr, node2.data.memory.ptr); + node2.data.size.rows = node2.data.capacity.rows; + + const cells_len = @as(usize, node2.data.capacity.cols) * + @as(usize, node2.data.capacity.rows); + const cells = node2.data.cells.ptr(node2.data.memory)[0..cells_len]; + try testing.expect(std.mem.allEqual( + u64, + @as([]const u64, @ptrCast(cells)), + 0, + )); + node2.data.assertIntegrity(); + s.destroyNode(node2); +} + test "PageList increaseCapacity from zero-capacity dimensions" { const testing = std.testing; const alloc = testing.allocator;