Skip to content

Keep the reservation when the array is emptied - #18

Open
lukewilliamboswell wants to merge 1 commit into
rdunnington:mainfrom
lukewilliamboswell:keep-reservation-when-emptied
Open

lukewilliamboswell wants to merge 1 commit into
rdunnington:mainfrom
lukewilliamboswell:keep-reservation-when-emptied

Conversation

@lukewilliamboswell

Copy link
Copy Markdown
Contributor

Emptying a StableArray with resize(0) and then growing it again leaks the whole reservation.

The bug

A capacity of zero is how the array records that it has no reservation yet. ensureTotalCapacity ended with an unconditional self.capacity = new_capacity, so it also lowered the capacity whenever it was asked for less than it already had. After resize(0):

  • the next growth sees capacity == 0, reserves a fresh range, and overwrites items.ptr, abandoning the old range and its committed pages;
  • deinit / clearAndFree skip the release, because they only free when capacity > 0.

shrinkAndFree(0) strands the reservation the same way after decommitting it.

var a = StableArray(u8).init(1024 * 1024);
defer a.deinit();
while (true) {
    try a.append(0);
    try a.resize(0); // one leaked 1 MiB reservation per iteration
}

The fix

  • ensureTotalCapacity returns early unless it is asked to grow, so it never lowers the capacity.
  • shrinkAndFree(0) releases the reservation, as clearAndFree does.

resize to a shorter length now keeps the capacity, which is what std.ArrayList.resize does.

Tests

Three new tests; each fails on main and passes with the change:

  • resize to a shorter length and to zero keeps the capacity and the same items.ptr.
  • An append / resize(0) cycle repeated more times than the address space could hold if each cycle leaked a reservation.
  • The same cycle with shrinkAndFree(0).

Run with Zig 0.16.0 on Windows: zig build test, zig build -Dtarget=x86-windows-gnu test, and -Doptimize=ReleaseSafe all pass 10/10. I have not run the Linux or macOS jobs locally.

How it showed up

bytebox's validator resets control_types with resize(0) after every function body, so decoding a module leaked one 1 MiB reservation per function. A long-lived Windows process that decoded a few thousand modules ran the machine out of commit charge.

🤖 Generated with Claude Code

`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 <noreply@anthropic.com>
@lukewilliamboswell
lukewilliamboswell marked this pull request as ready for review October 8, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant