# FixedArena A bump allocator over one block that is allocated up front, fully committed, and used as-is. `fixed_arena.jai`, no platform layer. - [Model](#model) - [Choosing between FixedArena and Arena](#choosing-between-fixedarena-and-arena) - [Quick start](#quick-start) - [Constants and types](#constants-and-types) - [Lifecycle](#lifecycle) - [Allocation](#allocation) - [Resizing](#resizing) - [Resetting](#resetting) - [Scoped helpers](#scoped-helpers) - [Allocator integration](#allocator-integration) - [Thread safety](#thread-safety) - [Correctness notes and limitations](#correctness-notes-and-limitations) - [Testing](#testing) --- ## Model A `FixedArena` takes one block of memory, reserves its first bytes for a header, and hands out the rest by advancing a cursor. It never grows. When the block is full, allocation returns `null` — that is the normal, expected way for a fixed arena to fail, and callers are meant to check for it. Three regions describe an arena, separated by two boundaries: ``` memory.data fixed_arena_start() memory.data + cursor memory.data + memory.count | | | | +---- header --------+---- live allocations -----+------- unallocated --------+ | FIXED_ARENA_ | handed out | never handed out | | HEADER_BYTES | | | ``` | Boundary | Meaning | |---|---| | `memory.data` | Start of the block. Null exactly when the arena is uninitialized. | | `fixed_arena_start()` | First allocatable byte. Always `memory.data + FIXED_ARENA_HEADER_BYTES`. | | `memory.data + cursor` | Bump position. Everything below it has been handed out. | | `memory.data + memory.count` | End of the block. Nothing is ever handed out at or past this. | The header bytes are reserved whether or not the arena is bootstrapped. For a bootstrapped arena that is where the `FixedArena` struct actually lives; for one set up over a caller's buffer those bytes go unused. Paying them either way buys a single address layout, so `fixed_arena_start`, `IS_THIS_YOURS` and the reset path need no special case. `arena.jai` makes the same trade for the same reason. Individual allocations cannot be freed. You reclaim in bulk with `fixed_arena_reset`, or scope it with `auto_reset`. **Alignment padding comes out of the block.** Each allocation begins at the next `FIXED_ARENA_DEFAULT_ALIGNMENT`-aligned address, and the bytes skipped to reach it are consumed like any others. So the room actually left is *not* `memory.count - cursor` — that overstates it by up to `FIXED_ARENA_DEFAULT_ALIGNMENT - 1` bytes. Ask `fixed_arena_available` instead. **Allocations are not zeroed.** Zeroing the block on creation would touch every page up front, and it would be a guarantee the first reset silently withdrew, since a reset does not re-zero. Zero what you need yourself. ## Choosing between FixedArena and Arena | | `FixedArena` | `Arena` | |---|---|---| | Backing memory | One block from an allocator, or a buffer you own | A large virtual reservation | | Growth | None; full means `null` | Commits pages on demand up to the reserve | | Platform calls | None | `VirtualAlloc` / `mmap` | | Alignment | Fixed at `FIXED_ARENA_DEFAULT_ALIGNMENT` (16) | Per-arena, settable, default 8 | | Reclaim | `fixed_arena_reset`, `auto_reset` | Reset, release, and the secure-erase variants | | Struct size | 40 bytes | 32 bytes in release, larger under `ARENA_DEBUG` | Reach for `FixedArena` when you know the budget up front, when you want the arena to sit inside memory you already have (a static buffer, a slab from somewhere else), or when you cannot make platform virtual-memory calls. Reach for `Arena` when the ceiling is unknown and you would rather reserve big and commit lazily. ## Quick start ```jai // Bootstrapped: the arena allocates its own block and lives inside it. arena := bootstrap_fixed_arena(64 * 1024); if !arena return; // too small, or the allocation failed defer delete_fixed_arena(arena); // `arena` dangles after this -- see below { push_arena(arena); // context.allocator is the arena for this scope auto_reset(arena); // and the cursor rewinds when the scope ends data := NewArray(100, float32); if !data.data return; // a full arena returns null; check it } ``` ```jai // Over memory you already own. The struct lives outside the block. backing: [8192] u8; arena: FixedArena; if !fixed_arena_init(*arena, backing) return; p := fixed_arena_alloc(*arena, 256); if !p return; ``` ## Constants and types ### Constants | Name | Value | Meaning | |---|---|---| | `FIXED_ARENA_DEFAULT_ALIGNMENT` | `16` | Every allocation is aligned to this, as an address. | | `FIXED_ARENA_HEADER_BYTES` | `size_of(FixedArena)` | Reserved at the base of every block. | | `FIXED_ARENA_MINIMUM_SIZE` | header + alignment | Smallest block either constructor accepts. | Note that `FIXED_ARENA_DEFAULT_ALIGNMENT` is 16 while `ARENA_DEFAULT_ALIGNMENT` in `arena.jai` is 8. The difference is deliberate but it matters: memory handed out by an `Arena` is only 8-aligned, so a `FixedArena` placed in it starts from a block whose base is not 16-aligned. The allocator aligns the *address* it returns rather than the offset into the block, so this works — but it is why the distinction exists. ### `FixedArena` ```jai FixedArena :: struct { memory: []u8; cursor: s64; allocator: Allocator; } ``` | Field | Meaning | |---|---| | `memory` | The block. `data` is null exactly when the arena is uninitialized. | | `cursor` | Offset of the bump position from `memory.data`. Starts at `FIXED_ARENA_HEADER_BYTES`. | | `allocator` | The allocator that owns `memory`. Read by `delete_fixed_arena`; null when the caller owns the block. | Do not write `cursor` directly. In particular `cursor = 0` is not a reset: for a bootstrapped arena it lets the next allocation overwrite the struct describing the block being allocated from. Use `fixed_arena_reset`. ## Lifecycle ### `bootstrap_fixed_arena` ```jai bootstrap_fixed_arena :: (size: s64) -> *FixedArena ``` Allocates a `size`-byte block from `context.allocator` and puts the arena's own header at the base of it, so nothing else has to own the struct. `size` covers the header as well as the memory you get to hand out, so the usable capacity is at most `size - FIXED_ARENA_HEADER_BYTES`, and less once alignment padding between allocations is accounted for. `fixed_arena_available` reports the real figure at any moment. Returns `null` if `size` is below `FIXED_ARENA_MINIMUM_SIZE`, or if the allocation fails. Both log. There is no partial success. **The pointer dangles after `delete_fixed_arena`.** The struct is inside the block being released, so it cannot be nulled for you. Do not read any field afterwards — `is_valid` included, since it dereferences and will fault rather than report anything useful. Null your own copy, and make sure no other copy outlives the delete. ### `fixed_arena_init` ```jai fixed_arena_init :: (arena: *FixedArena, memory: []u8, allocator := Allocator.{}) -> bool ``` Sets an arena up over memory the caller already owns — a static array, a slab from another arena — with the `FixedArena` struct outside the block rather than at its base. `allocator` is the allocator that owns `memory`. Leave it at its default when the caller owns the memory and nothing should ever free it: `delete_fixed_arena` then does nothing, which is the correct answer for a static buffer. Returns `false`, with an error logged, for a null `arena`, a null block, or a block below `FIXED_ARENA_MINIMUM_SIZE`. A failed init leaves the arena invalid rather than half-set-up. Unlike the bootstrapped case, the struct survives `delete_fixed_arena` and can be initialized again and reused. ### `delete_fixed_arena` ```jai delete_fixed_arena :: (arena: *FixedArena) ``` Releases the block through `arena.allocator` — the allocator that made it, which is not necessarily `context.allocator` at the point of the call. Tolerates `null`. Does nothing when `arena.allocator.proc` is null, which is the caller-owned-buffer case from `fixed_arena_init`. Do not call this inside a `push_arena(arena)` scope. It would work, but it reads as though the arena frees itself, and what actually happens depends on which allocator is installed. ### `is_valid` ```jai is_valid :: (arena: *FixedArena) -> bool ``` True when the arena has a block large enough to hold its header and a cursor inside that block. False for `null`. Every allocating entry point checks this first, so you do not need to. Not usable on a bootstrapped arena after `delete_fixed_arena`: it dereferences the pointer. ## Allocation ### `fixed_arena_alloc` ```jai fixed_arena_alloc :: (arena: *FixedArena, byte_count: s64) -> *void ``` Bump-allocates `byte_count` bytes, aligned to `FIXED_ARENA_DEFAULT_ALIGNMENT`. Returns `null` rather than a pointer past the end of the block. | Condition | Result | |---|---| | Fits | Aligned pointer; cursor advances to the end of the allocation. | | Does not fit | `null`, logged. **The cursor does not move**, so a retry with a smaller size is not penalised. | | `byte_count` is 0 | A valid pointer. No memory is handed out; the cursor moves only by alignment padding. | | `byte_count` is negative | `null`, logged. | | `byte_count` near `S64_MAX` | `null`, logged. The capacity test is written so it cannot overflow. | | `arena` is null or invalid | `null`, logged. | A zero-byte request always succeeds, including against an exactly full arena and regardless of where the block starts, because nothing is handed out and so there is nothing to align. It is the one request `fixed_arena_available` does not describe. Worth knowing if you are checking `alloc(0)` for null as a proxy for exhaustion — it is not one, in either direction. ### `fixed_arena_available` ```jai fixed_arena_available :: (arena: *FixedArena) -> s64 ``` The largest `byte_count` the next `fixed_arena_alloc` can satisfy. **This is not `memory.count - cursor`.** The next allocation starts at the next aligned address, and the padding to reach it comes out of the block, so the raw gap overstates what will fit by up to `FIXED_ARENA_DEFAULT_ALIGNMENT - 1` bytes. Asking for the raw gap is the mistake this function exists to prevent; it is what the test suite got wrong at three separate sites on its first run. Returns 0 for a null or invalid arena and for a block with no room left, so it is always safe to compare against without a null check first. A zero-byte request is exempt: it succeeds even when this reports 0. ```jai // Fill the rest of the block exactly. remaining := fixed_arena_available(arena); p := fixed_arena_alloc(arena, remaining); // succeeds; cursor now equals memory.count q := fixed_arena_alloc(arena, 1); // null ``` ### `fixed_arena_start` ```jai fixed_arena_start :: (arena: *FixedArena) -> *u8 ``` The first allocatable byte: `memory.data + FIXED_ARENA_HEADER_BYTES`. This is where `fixed_arena_reset` rewinds to, and the lower bound `IS_THIS_YOURS` tests against. ## Resizing ### `fixed_arena_resize` ```jai fixed_arena_resize :: (arena: *FixedArena, old_memory: *void, old_size: s64, requested_size: s64) -> *void ``` Resizes a block, extending or shrinking it **where it sits** when it is the most recent allocation — that is, when `old_memory + old_size` is exactly the current cursor. Anything else allocates a fresh block and copies `min(old_size, requested_size)` bytes. The in-place case matters more here than it would for a growable arena. Array growth is the dominant caller of `RESIZE`, and without it, growing an array to *N* elements consumes O(*N*²) bytes of a block that by definition cannot grow. Shrinking the most recent allocation reclaims the difference. A `null` `old_memory` is legal — growing an array that has not allocated yet — and is treated as a plain allocation. Returns `null` on a negative size, an invalid arena, or a request that does not fit. ## Resetting ### `fixed_arena_reset` ```jai fixed_arena_reset :: (arena: *FixedArena) ``` Hands the whole block back for reuse by rewinding the cursor to `fixed_arena_start` — **not to offset zero**. The header bytes stay reserved, which is what stops the next allocation from overwriting a bootstrapped arena's own struct. Previously allocated memory is not overwritten, only forgotten. If it held anything sensitive, wipe it yourself before resetting; unlike `arena.jai`, there is no `fixed_arena_reset_overwriting_memory`. Does nothing on a null or invalid arena. ## Scoped helpers ### `push_arena` ```jai push_arena :: (arena: *FixedArena) #expand ``` Installs the arena as `context.allocator` for the enclosing scope and restores the previous allocator on exit. Everything that allocates through the context — `New`, `NewArray`, `array_add`, `String_Builder` — comes out of the arena. It restores the allocator, **not the cursor**. A scope that allocates without an `auto_reset` alongside consumes from the block for the life of the program. ### `auto_reset` ```jai auto_reset :: (arena: *FixedArena) #expand ``` Records the cursor and restores it when the enclosing scope exits. Nests correctly. The usual pairing is both macros together: ```jai { push_arena(arena); auto_reset(arena); ... } ``` The mark holds the allocation position and nothing else, matching how `Arena_State` is scoped in `arena.jai`. Alignment and the backing allocator are configuration rather than position, and restoring them would silently undo unrelated changes a caller had made. ## Allocator integration ### `get_allocator` ```jai get_allocator :: (arena: *FixedArena) -> Allocator ``` Wraps the arena as an `Allocator` for passing to `,,` call sites, storing in a struct, or installing manually. ### `fixed_arena_allocator_proc` ```jai fixed_arena_allocator_proc :: (mode: Allocator_Mode, requested_size: s64, old_size: s64, old_memory: *void, allocator_data: *void) -> *void ``` The `Allocator_Proc`. Every mode is handled explicitly, under `#complete`, so a mode added upstream is a compile error here rather than a silent `null`. | Mode | Behaviour | |---|---| | `.ALLOCATE` | `fixed_arena_alloc`. | | `.RESIZE` | `fixed_arena_resize`. | | `.FREE` | `null`. A bump allocator cannot release an individual allocation. | | `.IS_THIS_YOURS` | True only for memory that is both **allocated by this arena and currently live** — between `fixed_arena_start` and the cursor. The header and the unallocated tail are not yours, and a reset un-owns everything it handed out. | | `.CAPS` | `IS_THIS_YOURS \| HINT_I_AM_A_FAST_BUMP_ALLOCATOR`, and fills in the name. | | `.STARTUP`, `.SHUTDOWN` | `null`. | | `.THREAD_START`, `.THREAD_STOP` | `null`. No per-thread state is built. | | `.CREATE_HEAP`, `.DESTROY_HEAP` | `null`. | A null `allocator_data` is survivable in every mode. `get_capabilities` in `module.jai` passes null outright, because capabilities are a property of the proc rather than of any one heap. ### Memory debugger `Memory_Debugger` deliberately ignores this allocator, the same way it ignores the pool and temporary allocators. Allocations from a `FixedArena` are not tracked, and an overrun here would not be caught for you — the only allocation the debugger sees is the block `bootstrap_fixed_arena` takes from `context.allocator`. That is why every bound this allocator cares about is checked inside `fixed_arena.jai` rather than left to tooling, and why `tests/fixed_arena_tests.jai` is the only regression net there is. ## Thread safety **None. Nothing in this file is thread-safe.** Allocation is an unsynchronized read-modify-write of `cursor`, so two threads allocating from one `FixedArena` will hand out overlapping memory. `.CAPS` correctly omits `Allocator_Caps.MULTIPLE_THREADS`, and `THREAD_START` / `THREAD_STOP` are accepted as no-ops rather than being used to build per-thread state. The dangerous part is that nothing stops you. `push_arena` installs the arena as `context.allocator`, and a context is exactly the sort of thing that gets copied to a worker. Safe patterns: - **One arena per thread.** The usual answer. - **Hand off ownership.** One thread fills an arena and passes it to another, provided there is a real happens-before edge and only one thread touches it at a time. - **Read-only sharing.** Multiple threads reading memory already allocated, as long as none of them allocates or resets. Unsafe, including cases that look benign: - Two threads calling `fixed_arena_alloc` on one arena, however briefly. - One thread allocating while another calls `fixed_arena_reset` or exits an `auto_reset` scope — the rewind can hand out live memory again. - Sharing a context that carries a fixed arena allocator. A lock around the allocating calls alone is not enough. It has to cover everything that touches the cursor: `fixed_arena_alloc`, `fixed_arena_resize`, `fixed_arena_reset`, `auto_reset`'s scope exit, and the allocator proc. ## Correctness notes and limitations - **A full arena returns `null`, it does not abort.** This is the normal failure mode and callers are expected to handle it. Nothing in the file asserts on exhaustion. - **Allocations are not zeroed**, and a reset does not zero either. - **There is no secure-erase path.** `arena.jai` has `arena_wipe` and the `*_overwriting_memory` variants; this file has no equivalent. Wipe sensitive memory yourself before resetting. - **`cursor` is an offset, not a pointer**, and the header bytes are reserved in both layouts. Do not persist a cursor value across a change of build configuration — `size_of(FixedArena)` is what sets the start, and a struct layout change moves it. - **The bootstrapped pointer dangles after delete.** Covered above; it is the single easiest way to misuse this file. - **No alignment control.** Alignment is fixed at `FIXED_ARENA_DEFAULT_ALIGNMENT`. There is no equivalent of `arena_set_alignment` or `push_alignment`. If you need a larger alignment for a particular allocation, over-allocate and align by hand. - **In-place resize only helps the most recent allocation.** Resizing anything buried under a later allocation copies, and the original is stranded until the next reset. ## Testing `tests/fixed_arena_tests.jai` covers every exported symbol across seven groups, each behind a `RUN_*_TESTS` constant so groups can be enabled individually. The module-scope state machinery (`Fixed_Arena_State` and its pair) is exercised through `auto_reset`. Each test names the review finding it guards, so a failure points at the reasoning as well as the line. The reasoning lives in `fixed-arena-code-review.html`. Two conventions worth knowing before reading the output: - Tests that drive failure paths deliberately log. Those are bracketed by `(expecting logged errors: ...)` banners — noise inside a banner is the test working, noise outside one is not. - The suite reports per group rather than as one total. A check is one `expect` call reached at runtime, not one distinct property, so a check inside a loop counts once per iteration. The suite passes: 165 checks over seven groups. Its first run failed 8 of 152 checks, all of them the same defect and all of them in the tests: three sites computed the room left as `memory.count - cursor` and so asked for more than could fit. The allocator was right to refuse every time. What the failures actually exposed was a missing piece of API — there was no way for a caller to ask how much would fit — which is why `fixed_arena_available` exists and why the raw-gap calculation is called out twice above. With that closed, and with a zero-byte request made independent of the block's base address, the suite runs clean. The cases most worth keeping if the file is ever trimmed are the boundary ones: allocating to exact capacity and one byte past it, a zero-byte request against a full arena, and the alignment group's deliberately 8-but-not-16-aligned block. Those are the three places where the obvious implementation is wrong in a way that ordinary use does not reveal.