diff --git a/galloc.go b/galloc.go index b0f0c33..90826d7 100644 --- a/galloc.go +++ b/galloc.go @@ -79,6 +79,7 @@ type Allocator struct { nodes []node freeNodes []uint32 freeOffset uint32 + allocCount uint32 } // New creates an Allocator that manages a contiguous range of size units @@ -91,18 +92,41 @@ func New(size, maxAllocs uint32) *Allocator { size: size, maxAllocs: maxAllocs, } - a.nodes = make([]node, maxAllocs) - a.freeNodes = make([]uint32, maxAllocs) + // A free region cannot be adjacent to another free region (Free coalesces + // neighbors), so with K active allocations there are at most K+1 free + // regions. The +1 case is reachable only below the active-allocation cap; + // once maxAllocs is reached, no split can add another region. Thus the + // reachable region count is bounded by twice maxAllocs. Keep one node for + // the initial free region when maxAllocs is zero. + nodeCount := allocatorNodeCount(maxAllocs) + a.nodes = make([]node, nodeCount) + a.freeNodes = make([]uint32, nodeCount) a.reset() return a } +// allocatorNodeCount returns the maximum number of reachable spatial regions +// while honoring maxAllocs. Keep the result representable by both int (for +// slice lengths) and uint32 (for node indices and the NoSpace sentinel). +func allocatorNodeCount(maxAllocs uint32) int { + count := uint64(maxAllocs) * 2 + if count == 0 { + count = 1 + } + maxInt := uint64(^uint(0) >> 1) + if count > uint64(NoSpace) || count > maxInt { + panic("galloc: maxAllocs is too large") + } + return int(count) +} + // reset reinitializes the allocator to its initial state: a single free // region spanning the entire managed range. func (a *Allocator) reset() { a.freeStorage = 0 a.usedBinsTop = 0 - a.freeOffset = a.maxAllocs - 1 + a.allocCount = 0 + a.freeOffset = uint32(len(a.freeNodes)) - 1 for i := range a.usedBins { a.usedBins[i] = 0 @@ -117,8 +141,8 @@ func (a *Allocator) reset() { } // Freelist is a stack. Nodes in inverse order so that index 0 pops first. - for i := uint32(0); i < a.maxAllocs; i++ { - a.freeNodes[i] = a.maxAllocs - i - 1 + for i := range a.freeNodes { + a.freeNodes[i] = uint32(len(a.freeNodes) - i - 1) } // Start state: whole storage as one big node. @@ -141,8 +165,8 @@ func (a *Allocator) Reset() { func (a *Allocator) Allocate(size uint32) Allocation { fail := Allocation{Offset: NoSpace, Metadata: NoSpace} - // Out of node slots? - if a.freeOffset == 0 { + // Out of advertised allocation slots? + if a.allocCount >= a.maxAllocs { return fail } @@ -174,11 +198,18 @@ func (a *Allocator) Allocate(size uint32) Allocation { } binIndex := (topBinIndex << topBinsIndexShift) | leafBinIndex - - // Pop the top node of the bin. Bin top = node.next. nodeIndex := a.binIndices[binIndex] nd := &a.nodes[nodeIndex] nodeTotalSize := nd.dataSize + + // Splitting a free region needs an additional node for its remainder. A + // fragmented layout can exhaust the node pool before maxAllocs is reached; + // fail cleanly instead of indexing the freelist sentinel. + if nodeTotalSize > size && a.freeOffset == nodeUnused { + return fail + } + + // Pop the top node of the bin. Bin top = node.next. nd.dataSize = size nd.used = true a.binIndices[binIndex] = nd.binListNext @@ -214,6 +245,7 @@ func (a *Allocator) Allocate(size uint32) Allocation { a.nodes[newNodeIndex].neighborNext = nd.neighborNext nd.neighborNext = newNodeIndex } + a.allocCount++ return Allocation{ Offset: nd.dataOffset, @@ -238,6 +270,9 @@ func (a *Allocator) AllocateAligned(size, alignment uint32) Allocation { if alignment&(alignment-1) != 0 { panic("galloc: alignment must be a power of two") } + if size > NoSpace-(alignment-1) { + return Allocation{Offset: NoSpace, Metadata: NoSpace} + } padded := size + alignment - 1 alloc := a.Allocate(padded) if alloc.Failed() { @@ -264,6 +299,7 @@ func (a *Allocator) Free(alloc Allocation) { if !nd.used { panic("galloc: double free detected") } + a.allocCount-- offset := nd.dataOffset size := nd.dataSize @@ -325,8 +361,9 @@ func (a *Allocator) StorageReport() StorageReport { var largestFreeRegion uint32 var freeStorage uint32 - // Out of node slots? -> report zero free space. - if a.freeOffset > 0 { + // Out of node slots? -> report zero free space. freeOffset is a stack + // index, so zero still represents one available node. + if a.freeOffset != nodeUnused { freeStorage = a.freeStorage if a.usedBinsTop != 0 { topBinIndex := 31 - uint32(bits.LeadingZeros32(a.usedBinsTop)) //nolint:gosec // LeadingZeros32 returns [0,32], safe for uint32 diff --git a/galloc_test.go b/galloc_test.go index a2ab142..d87f180 100644 --- a/galloc_test.go +++ b/galloc_test.go @@ -4,6 +4,7 @@ package galloc import ( + "math" "sync" "testing" ) @@ -25,6 +26,74 @@ func TestAllocateBasic(t *testing.T) { a.Free(alloc) } +func TestSingleAllocationSlotWithRemainder(t *testing.T) { + a := New(1024, 1) + + alloc := a.Allocate(1) + if alloc.Failed() { + t.Fatal("Allocate(1) failed with one allocation slot") + } + if alloc.Offset != 0 { + t.Errorf("allocation offset = %d, want 0", alloc.Offset) + } + if got := a.AllocationSize(alloc); got != 1 { + t.Errorf("AllocationSize = %d, want 1", got) + } + + // maxAllocs limits simultaneous allocations. Once the slot is occupied, + // another request must fail cleanly even though the remainder is free. + beforeFailure := a.StorageReport() + if next := a.Allocate(1); !next.Failed() { + t.Fatal("second allocation should fail when the only slot is occupied") + } + if afterFailure := a.StorageReport(); afterFailure != beforeFailure { + t.Errorf("failed allocation changed storage report: before=%#v after=%#v", beforeFailure, afterFailure) + } + + a.Free(alloc) + reused := a.Allocate(1) + if reused.Failed() { + t.Fatal("allocation after freeing the only slot failed") + } + a.Free(reused) + + report := a.StorageReport() + if report.TotalFreeSpace != 1024 { + t.Errorf("TotalFreeSpace after free/reuse = %d, want 1024", report.TotalFreeSpace) + } +} + +func TestZeroAllocationSlots(t *testing.T) { + a := New(1024, 0) + + if alloc := a.Allocate(1); !alloc.Failed() { + t.Fatalf("Allocate(1) with zero allocation slots = %#v, want failure", alloc) + } +} + +func TestExcessiveAllocationCapacityPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("New with unrepresentable allocation capacity should panic") + } + }() + + New(1, math.MaxUint32) +} + +func TestAllocateSplitWithoutFreeNodeFails(t *testing.T) { + a := New(1024, 1) + a.freeOffset = nodeUnused // Simulate exhausted structural metadata. + before := a.StorageReport() + + if alloc := a.Allocate(1); !alloc.Failed() { + t.Fatalf("Allocate(1) without a remainder node = %#v, want failure", alloc) + } + if after := a.StorageReport(); after != before { + t.Errorf("failed split changed storage report: before=%#v after=%#v", before, after) + } +} + func TestAllocateMultiple(t *testing.T) { a := New(testSize256MB, testMaxAllocs) @@ -274,9 +343,8 @@ func TestMaxAllocsExhaustion(t *testing.T) { const maxAllocs = 8 a := New(1024, maxAllocs) - // Each allocation consumes a node. The initial free region also consumes - // one node, plus the remainder from each split. With maxAllocs=8, we can - // make fewer than 8 simultaneous allocations because of remainder nodes. + // The node pool includes enough structural slots for free regions and split + // remainders, while maxAllocs bounds simultaneous allocations. var allocs []Allocation for i := 0; i < 100; i++ { alloc := a.Allocate(1) @@ -313,6 +381,38 @@ func TestMaxAllocsExhaustion(t *testing.T) { a.Free(recovered) } +func TestFragmentedNodeCapacity(t *testing.T) { + // Keep several non-adjacent holes while five of the eight allocation slots + // remain active. The allocator still has ample contiguous storage, and the + // request must be able to split one of the holes rather than fail due to + // exhausted structural nodes. + a := New(4096, 8) + allocs := make([]Allocation, 8) + for i := range allocs { + allocs[i] = a.Allocate(100) + if allocs[i].Failed() { + t.Fatalf("initial allocation %d failed", i) + } + } + + for _, i := range []int{1, 3, 5} { + a.Free(allocs[i]) + } + + split := a.Allocate(62) + if split.Failed() { + t.Fatal("allocation in fragmented free space failed before maxAllocs") + } + a.Free(split) + + for _, i := range []int{0, 2, 4, 6, 7} { + a.Free(allocs[i]) + } + if report := a.StorageReport(); report.TotalFreeSpace != 4096 { + t.Errorf("TotalFreeSpace after cleanup = %d, want 4096", report.TotalFreeSpace) + } +} + func TestStorageReport(t *testing.T) { a := New(1024, 256) @@ -611,6 +711,21 @@ func TestAllocateAlignedNonPowerOfTwoPanics(t *testing.T) { a.AllocateAligned(100, 3) } +func TestAllocateAlignedOverflowFails(t *testing.T) { + const alignment = 4 + size := uint32(math.MaxUint32) + a := New(size, 2) + before := a.StorageReport() + + alloc := a.AllocateAligned(size, alignment) + if !alloc.Failed() { + t.Fatalf("AllocateAligned(%d, %d) should fail on padded-size overflow: %#v", size, alignment, alloc) + } + if after := a.StorageReport(); after != before { + t.Errorf("overflow failure changed storage report: before=%#v after=%#v", before, after) + } +} + func TestAllocateAlignedExhaustion(t *testing.T) { // Small allocator — aligned allocations waste padding, should exhaust faster. a := New(1024, 256)