Found on the box while explaining why a Roc chat route took 6x its
payload in request memory (metal-vmm FEEDBACK, 2026-10-26: two of the six
were this). Reproduced with 0.17.0 and master 0.18.0-dev.610+720939f8c;
the lines are unchanged on Codeberg master today. The fix was checked on a
patched copy of the file: the repro, and the file's own tests, the
multi-threaded fuzz included.
Title: std.heap.ArenaAllocator: a request that doesn't fit the current node grows it by twice the request
Zig version: 0.17.0 and master 0.18.0-dev.610+720939f8c, x86_64-linux.
Repro (overshoot.zig): an arena over a child that can grow its last
allocation in place (FixedBufferAllocator), with a logging wrapper so the
child's requests show.
const std = @import("std");
const Logging = struct {
child: std.mem.Allocator,
fn allocator(self: *Logging) std.mem.Allocator {
return .{ .ptr = self, .vtable = &.{ .alloc = alloc, .resize = resize, .remap = remap, .free = free } };
}
fn alloc(ctx: *anyopaque, n: usize, a: std.mem.Alignment, ra: usize) ?[*]u8 {
const self: *Logging = @ptrCast(@alignCast(ctx));
std.debug.print(" child alloc {d}\n", .{n});
return self.child.rawAlloc(n, a, ra);
}
fn resize(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, n: usize, ra: usize) bool {
const self: *Logging = @ptrCast(@alignCast(ctx));
std.debug.print(" child resize {d} -> {d}\n", .{ m.len, n });
return self.child.rawResize(m, a, n, ra);
}
fn remap(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, n: usize, ra: usize) ?[*]u8 {
const self: *Logging = @ptrCast(@alignCast(ctx));
return self.child.rawRemap(m, a, n, ra);
}
fn free(ctx: *anyopaque, m: []u8, a: std.mem.Alignment, ra: usize) void {
const self: *Logging = @ptrCast(@alignCast(ctx));
self.child.rawFree(m, a, ra);
}
};
pub fn main() !void {
var buf: [1 << 20]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buf);
var logging: Logging = .{ .child = fba.allocator() };
var arena: std.heap.ArenaAllocator = .init(logging.allocator());
defer arena.deinit();
const a = arena.allocator();
std.debug.print("alloc 100:\n", .{});
_ = try a.alloc(u8, 100);
std.debug.print("alloc 100000 (does not fit the first node):\n", .{});
_ = try a.alloc(u8, 100_000);
std.debug.print("arena holds {d} bytes for 100,100 requested\n", .{arena.queryCapacity()});
}
zig run overshoot.zig
Actual:
alloc 100:
child alloc 212
alloc 100000 (does not fit the first node):
child resize 212 -> 200124
arena holds 200100 bytes for 100,100 requested
Expected: a resize to about 100,124 (the 24-byte header, the 100 bytes in use, the 100,000 requested); about 100,100 held.
Cause: alloc's fast path reserves with an atomic add before it checks
the fit (ArenaAllocator.zig:358):
const end_index = @atomicRmw(usize, &node.end_index, .Add, alignable, .acquire);
...
if (aligned_index + n > buf.len) break :first_node .{ node, buf.len };
A request that doesn't fit leaves its reservation in node.end_index,
past the end of buf. The resize path then reads that end_index back and
computes the new size from it (:387-389):
const end_index = @atomicLoad(usize, &node.end_index, .monotonic);
const aligned_index = alignedIndex(buf.ptr, end_index, alignment);
const new_size = mem.alignForward(usize, @sizeOf(Node) + aligned_index + n, 2);
so n is counted twice: once as the failed reservation, once as the
request. On success the cmpxchg sets end_index to aligned_index + n,
so the first n bytes after the old end are never used.
Impact: any arena that grows by resizing pays up to twice for its
large allocations. reset(.retain_capacity) then keeps the doubled
capacity, and when it can't resize it allocates a block that size afresh.
In our server a request copying an N-byte buffer through an arena cost 2N
for the copy, then 2N more held by the reset.
Fix: nothing past buf.len is in use (every allocation that
succeeded ended at or before it), so grow from there when end_index has
overshot:
--- a/lib/std/heap/ArenaAllocator.zig
+++ b/lib/std/heap/ArenaAllocator.zig
@@ fn alloc(
const buf = allocated_slice[@sizeOf(Node)..];
const end_index = @atomicLoad(usize, &node.end_index, .monotonic);
- const aligned_index = alignedIndex(buf.ptr, end_index, alignment);
+ // A fast-path reservation that did not fit is left in `end_index`, past
+ // the end of `buf`; nothing beyond `buf.len` is in use, so grow from there,
+ // not from the overshoot, or the node grows by the request twice.
+ const aligned_index = alignedIndex(buf.ptr, @min(end_index, buf.len), alignment);
const new_size = mem.alignForward(usize, @sizeOf(Node) + aligned_index + n, 2);
The cmpxchg after the resize still expects the loaded end_index, so a
concurrent allocation between the load and the swap fails it as before.
With the fix the repro prints child resize 212 -> 100212 and arena holds
100188 bytes, and the file's tests (the multi-threaded fuzz included)
pass. The old node's unused tail (88 bytes here) is still skipped; using
the failing caller's own pre-add end_index would reclaim it in the
single-threaded case, at the cost of carrying it out of the fast path.