From d789d1ac7996827588f52c68e989bc56aafd482e Mon Sep 17 00:00:00 2001 From: Luke Boswell Date: Thu, 8 Oct 2026 18:17:58 +1100 Subject: [PATCH] Keep the reservation when the array is emptied `ensureTotalCapacity` assigned the requested capacity unconditionally, so `resize` to a shorter length lowered `capacity` without releasing anything. A capacity of zero is how the array records that it holds no reservation, so after `resize(0)` the next growth reserved a fresh address range and abandoned the previous one together with its committed pages, and `deinit` skipped the release as well. `shrinkAndFree(0)` stranded the reservation the same way after decommitting it. `ensureTotalCapacity` now only ever raises the capacity, and `shrinkAndFree(0)` releases the reservation like `clearAndFree`. Co-Authored-By: Claude Opus 5.5 --- stable_array.zig | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/stable_array.zig b/stable_array.zig index e977d33..463ea31 100644 --- a/stable_array.zig +++ b/stable_array.zig @@ -233,6 +233,13 @@ pub fn StableArrayAligned(comptime T: type, comptime _alignment: u29) type { pub fn shrinkAndFree(self: *Self, new_len: usize) void { assert(new_len <= self.items.len); + // A capacity of zero means there is no reservation, so shrinking + // to nothing has to release the reservation rather than strand it. + if (new_len == 0) { + self.clearAndFree(); + return; + } + const new_capacity_bytes = self.calcBytesUsedForCapacity(new_len); const current_capacity_bytes: usize = self.calcBytesUsedForCapacity(self.capacity); @@ -293,6 +300,12 @@ pub fn StableArrayAligned(comptime T: type, comptime _alignment: u29) type { } pub fn ensureTotalCapacity(self: *Self, new_capacity: usize) AllocError!void { + // Never lower the capacity here: it records how much of the + // reservation is committed, and a capacity of zero means there is + // no reservation at all. Lowering it to zero would make the next + // growth reserve a fresh range and abandon this one. + if (new_capacity <= self.capacity) return; + const new_capacity_bytes = self.calcBytesUsedForCapacity(new_capacity); const current_capacity_bytes: usize = self.calcBytesUsedForCapacity(self.capacity); @@ -514,3 +527,54 @@ test "growing retains values" { try a.resize(a.getPageSize() * 2); assert(a.items[0] == 0xFF); } + +test "resize smaller keeps capacity and the reservation" { + var a = StableArray(u8).init(TEST_VIRTUAL_ALLOC_SIZE); + defer a.deinit(); + + try a.resize(a.getPageSize() * 2); + const ptr = a.items.ptr; + const capacity = a.capacity; + + try a.resize(1); + assert(a.items.len == 1); + assert(a.capacity == capacity); + + try a.resize(0); + assert(a.items.len == 0); + assert(a.capacity == capacity); + + try a.append(0xFF); + assert(a.items.ptr == ptr); + assert(a.capacity == capacity); +} + +// A leaked reservation holds its whole address range, so emptying and +// refilling more often than the address space has room for fails with +// OutOfMemory if any reservation is abandoned along the way. +const LEAK_TEST_RESERVE_SIZE = if (@sizeOf(usize) < @sizeOf(u64)) 1024 * 1024 * 256 else 1024 * 1024 * 1024 * 64; +const LEAK_TEST_CYCLES = if (@sizeOf(usize) < @sizeOf(u64)) 64 else 8192; + +test "resize to zero and refill does not leak reservations" { + var a = StableArray(u8).init(LEAK_TEST_RESERVE_SIZE); + defer a.deinit(); + + var cycle: usize = 0; + while (cycle < LEAK_TEST_CYCLES) : (cycle += 1) { + try a.append(0xFF); + try a.resize(0); + } +} + +test "shrinkAndFree to zero and refill does not leak reservations" { + var a = StableArray(u8).init(LEAK_TEST_RESERVE_SIZE); + defer a.deinit(); + + var cycle: usize = 0; + while (cycle < LEAK_TEST_CYCLES) : (cycle += 1) { + try a.append(0xFF); + a.shrinkAndFree(0); + assert(a.items.len == 0); + assert(a.capacity == 0); + } +}