mcap/modules/Basic/tests/fixed_arena_tests.jai

926 lines
37 KiB
Plaintext

// Test suite for fixed_arena.jai.
//
// module.jai #loads this file when Basic is imported with LOAD_TESTS = true, so it and the
// module are one compilation unit and the #scope_module machinery (Fixed_Arena_State,
// get_fixed_arena_state, restore_fixed_arena_state) is in reach. It is still exercised
// through auto_reset on purpose, because the public contract is what callers depend on.
//
// Covers every exported symbol of the file.
//
// Groups are toggled with the RUN_* constants below. Each group is #if'd at its call site
// in run_fixed_arena_tests(), so a disabled group is not invoked at all. Structure follows
// tests/arena_tests.jai deliberately -- same harness, same reporting, same conventions --
// so that the two suites read as one thing.
//
// Several groups deliberately drive failure paths that log. Expected log_error output is
// bracketed by "expecting errors" banners -- noise inside a banner is the test working,
// noise outside one is not.
//
// Each test names the review finding it guards, so a failure points at the reasoning as
// well as the line. See fixed-arena-code-review.html.
#scope_file
// ---------------------------------------------------------------------------
// Group switches
// ---------------------------------------------------------------------------
RUN_LIFECYCLE_TESTS :: true; // bootstrap_fixed_arena, fixed_arena_init, delete, is_valid
RUN_ALLOCATION_TESTS :: true; // fixed_arena_alloc happy paths, cursor bookkeeping
RUN_BOUNDS_TESTS :: true; // exhaustion, exact capacity, negative and overflowing sizes
RUN_ALIGNMENT_TESTS :: true; // every result aligned as an address, not as an offset
RUN_RESIZE_TESTS :: true; // in-place growth and shrink, copy fallback, null old_memory
RUN_RESET_TESTS :: true; // fixed_arena_reset, auto_reset, the header survives both
RUN_ALLOCATOR_TESTS :: true; // the allocator proc's modes, get_allocator, get_capabilities
VERBOSE :: false; // print cursor positions as groups run
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
// Per-group tallies. Fixed size and allocation-free on purpose: expect() is called from
// inside push_arena scopes, so anything it allocated would come out of the arena under
// test and be freed the moment that arena is reset or deleted.
MAX_GROUPS :: 16;
group_names : [MAX_GROUPS] string;
group_checks : [MAX_GROUPS] int;
group_fails : [MAX_GROUPS] int;
group_count := 0;
checks_run := 0;
checks_failed := 0;
// Set by run_fixed_arena_tests. One suite is one work item, so a file-scope pointer is
// enough; null means write straight to stdout, as a synchronous or standalone run does.
output_builder: *String_Builder;
emit :: (format_string: string, args: ..Any) {
if output_builder print_to_builder(output_builder, format_string, ..args);
else print(format_string, ..args);
} @PrintLike
group :: (name: string) {
assert(group_count < MAX_GROUPS, "raise MAX_GROUPS");
group_names[group_count] = name;
group_count += 1;
emit("\n-- % --\n", name);
}
expect :: (condition: bool, format: string, args: ..Any, loc := #caller_location) {
checks_run += 1;
if group_count group_checks[group_count-1] += 1;
if condition return;
checks_failed += 1;
if group_count group_fails[group_count-1] += 1;
name := ifx group_count then group_names[group_count-1] else "?";
emit(" FAIL [%:%] %\n", name, loc.line_number, tprint(format, ..args));
}
expecting_errors :: (what: string) {
emit(" (expecting logged errors: %)\n", what);
}
is_aligned :: (pointer: *void, alignment: s64) -> bool {
return (cast(s64) pointer) % alignment == 0;
}
fill :: (memory: *void, count: s64, value: u8) {
bytes := cast(*u8) memory;
for 0..count-1 bytes[it] = value;
}
all_bytes_are :: (memory: *void, count: s64, value: u8) -> bool {
bytes := cast(*u8) memory;
for 0..count-1 if bytes[it] != value return false;
return true;
}
// How much of a block of `size` bytes is actually available to hand out, ignoring the
// alignment padding between allocations.
usable_bytes :: (size: s64) -> s64 {
return size - FIXED_ARENA_HEADER_BYTES;
}
TEST_SIZE :: 4096;
// ---------------------------------------------------------------------------
// Lifecycle: bootstrap_fixed_arena, fixed_arena_init, delete_fixed_arena, is_valid
// ---------------------------------------------------------------------------
lifecycle_tests :: () {
group("lifecycle");
// A zeroed FixedArena is not valid until it is initialized. Regression guard for M4:
// the old is_valid tested `count >= 0`, which holds for every well-formed slice.
{
a: FixedArena;
expect(!is_valid(*a), "a zeroed FixedArena reported valid");
}
// Bootstrapping establishes every invariant the rest of the file depends on.
{
arena := bootstrap_fixed_arena(TEST_SIZE);
expect(arena != null, "bootstrap_fixed_arena(%) returned null", TEST_SIZE);
if arena {
expect(is_valid(arena), "a freshly bootstrapped arena is not valid");
expect(arena.memory.count == TEST_SIZE, "block is % bytes, expected %", arena.memory.count, TEST_SIZE);
expect(arena.cursor == FIXED_ARENA_HEADER_BYTES,
"cursor starts at %, expected % so the header is not handed out", arena.cursor, FIXED_ARENA_HEADER_BYTES);
// The header lives at the base of the block it describes.
expect(cast(*void)arena == cast(*void)arena.memory.data,
"the bootstrapped header is not at the base of its own block");
expect(fixed_arena_start(arena) == arena.memory.data + FIXED_ARENA_HEADER_BYTES,
"fixed_arena_start does not skip the header");
expect(arena.allocator.proc != null, "bootstrap did not record the allocator that made the block");
delete_fixed_arena(arena);
// `arena` is dangling from here; do not read it. See the comment on
// bootstrap_fixed_arena.
}
}
// Sizes below the header are refused rather than writing the header past the end of the
// allocation. Regression guard for C3.
{
expecting_errors("bootstrap with sizes below the minimum");
expect(bootstrap_fixed_arena(0) == null, "bootstrap_fixed_arena(0) returned an arena");
expect(bootstrap_fixed_arena(-1) == null, "bootstrap_fixed_arena(-1) returned an arena");
expect(bootstrap_fixed_arena(FIXED_ARENA_MINIMUM_SIZE - 1) == null,
"bootstrap accepted % bytes, one below the minimum of %",
FIXED_ARENA_MINIMUM_SIZE - 1, FIXED_ARENA_MINIMUM_SIZE);
}
// The minimum itself is accepted, so the boundary is where it claims to be.
{
arena := bootstrap_fixed_arena(FIXED_ARENA_MINIMUM_SIZE);
expect(arena != null, "bootstrap refused exactly FIXED_ARENA_MINIMUM_SIZE (%)", FIXED_ARENA_MINIMUM_SIZE);
if arena delete_fixed_arena(arena);
}
// fixed_arena_init wraps memory the caller owns, with the struct outside the block.
// Regression guard for L3.
{
buffer: [TEST_SIZE] u8;
a: FixedArena;
ok := fixed_arena_init(*a, buffer);
expect(ok, "fixed_arena_init over a % byte buffer failed", TEST_SIZE);
expect(is_valid(*a), "an arena initialized over a caller buffer is not valid");
expect(a.memory.data == buffer.data, "fixed_arena_init did not record the caller's block");
expect(a.cursor == FIXED_ARENA_HEADER_BYTES,
"fixed_arena_init starts at %, expected % -- both layouts share one address map",
a.cursor, FIXED_ARENA_HEADER_BYTES);
expect(a.allocator.proc == null, "fixed_arena_init invented an allocator for caller-owned memory");
// Deleting it must not try to free the caller's buffer, and must leave the struct
// usable, unlike the bootstrapped case.
delete_fixed_arena(*a);
expect(is_valid(*a), "delete_fixed_arena damaged an arena over caller-owned memory");
}
// Bad arguments to fixed_arena_init are refused.
{
expecting_errors("fixed_arena_init with a null arena and an undersized block");
small: [8] u8;
a: FixedArena;
expect(!fixed_arena_init(null, small), "fixed_arena_init(null, ...) reported success");
expect(!fixed_arena_init(*a, small),
"fixed_arena_init accepted an 8 byte block, below the minimum of %", FIXED_ARENA_MINIMUM_SIZE);
expect(!is_valid(*a), "a failed fixed_arena_init left the arena looking valid");
}
// delete_fixed_arena tolerates null. Regression guard for H2.
{
delete_fixed_arena(null);
expect(true, "delete_fixed_arena(null) did not fault");
}
}
// ---------------------------------------------------------------------------
// Allocation: fixed_arena_alloc happy paths and cursor bookkeeping
// ---------------------------------------------------------------------------
allocation_tests :: () {
group("allocation");
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping allocation tests");
return;
}
defer delete_fixed_arena(arena);
// A plain allocation lands inside the block, past the header.
{
p := fixed_arena_alloc(arena, 64);
expect(p != null, "a 64 byte allocation from a % byte arena returned null", TEST_SIZE);
expect(p >= fixed_arena_start(arena), "an allocation landed inside the header region");
expect(p < cast(*void)(arena.memory.data + arena.memory.count), "an allocation landed past the end of the block");
// The whole request is writable. This is the check that would have caught C1 in a
// build with array bounds checking disabled.
fill(p, 64, 0xAB);
expect(all_bytes_are(p, 64, 0xAB), "the allocated range did not survive being written");
}
// Successive allocations do not overlap.
{
fixed_arena_reset(arena);
a := fixed_arena_alloc(arena, 100);
b := fixed_arena_alloc(arena, 100);
expect(a != null && b != null, "allocations failed early in a % byte arena", TEST_SIZE);
if a && b {
fill(a, 100, 0x11);
fill(b, 100, 0x22);
expect(all_bytes_are(a, 100, 0x11), "the second allocation overlapped the first");
expect(cast(*u8)b >= cast(*u8)a + 100, "the second allocation starts inside the first");
}
}
// A zero-byte allocation is legal and does not move the cursor other than by alignment.
{
fixed_arena_reset(arena);
before := arena.cursor;
p := fixed_arena_alloc(arena, 0);
expect(p != null, "a zero-byte allocation returned null");
expect(arena.cursor >= before, "a zero-byte allocation moved the cursor backwards");
expect(arena.cursor - before < FIXED_ARENA_DEFAULT_ALIGNMENT,
"a zero-byte allocation consumed % bytes, more than alignment padding", arena.cursor - before);
}
// The cursor tracks what was handed out.
{
fixed_arena_reset(arena);
p := fixed_arena_alloc(arena, 200);
expect(p != null, "a 200 byte allocation returned null");
if p {
end := cast(s64)(cast(*u8)p - arena.memory.data) + 200;
expect(arena.cursor == end, "cursor is % after a 200 byte allocation ending at %", arena.cursor, end);
}
}
#if VERBOSE emit(" cursor after allocation tests: %\n", arena.cursor);
}
// ---------------------------------------------------------------------------
// Bounds: exhaustion, exact capacity, negative and overflowing sizes
// ---------------------------------------------------------------------------
bounds_tests :: () {
group("bounds");
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping bounds tests");
return;
}
defer delete_fixed_arena(arena);
// Allocating past capacity returns null and leaves the cursor where it was. Regression
// guard for C1, and for H5 on the cursor half.
{
expecting_errors("an allocation larger than the block");
fixed_arena_reset(arena);
before := arena.cursor;
p := fixed_arena_alloc(arena, TEST_SIZE * 2);
expect(p == null, "an allocation twice the size of the block succeeded");
expect(arena.cursor == before,
"a rejected allocation moved the cursor from % to % -- alignment padding was consumed on failure",
before, arena.cursor);
}
// The block can be filled exactly, and the next byte fails. Regression guard for C1's
// off-by-one at exact capacity: the old code indexed arena.memory[cursor], which is out
// of bounds when cursor == count even for a zero-byte request.
{
expecting_errors("one byte past a full block");
fixed_arena_reset(arena);
// Fill to the end. Ask fixed_arena_available_bytes rather than computing
// `memory.count - cursor`: the next allocation starts at the next aligned address, and
// the padding to reach it comes out of the block too.
first := fixed_arena_alloc(arena, 1);
expect(first != null, "a one byte allocation failed on an empty arena");
remaining := fixed_arena_available_bytes(arena);
expect(remaining < arena.memory.count - arena.cursor + 1,
"fixed_arena_available_bytes (%) exceeds the raw gap to the end of the block (%)",
remaining, arena.memory.count - arena.cursor);
rest := fixed_arena_alloc(arena, remaining);
expect(rest != null, "could not allocate the remaining % bytes to fill the block exactly", remaining);
expect(arena.cursor == arena.memory.count,
"after filling exactly, cursor is % and capacity is %", arena.cursor, arena.memory.count);
// A zero-byte request against a full arena is still legal: no memory is handed out.
zero := fixed_arena_alloc(arena, 0);
expect(zero != null, "a zero-byte allocation against an exactly full arena was refused");
// One real byte is not.
one := fixed_arena_alloc(arena, 1);
expect(one == null, "allocated a byte past the end of a full block");
// And a full arena reports nothing available.
expect(fixed_arena_available_bytes(arena) == 0,
"a full arena reports % bytes available", fixed_arena_available_bytes(arena));
}
// fixed_arena_available_bytes is exactly the largest request that can succeed -- not an estimate,
// and not the raw gap `memory.count - cursor`, which overstates it by the alignment padding.
// This is the invariant the suite's first run got wrong at three separate sites.
{
expecting_errors("one byte more than fixed_arena_available_bytes reports");
// Start from an odd cursor so there is real padding to account for.
fixed_arena_reset(arena);
fixed_arena_alloc(arena, 3);
available := fixed_arena_available_bytes(arena);
expect(available > 0, "an arena with one 3 byte allocation reports nothing available");
// One more than it reports must fail, and must not move the cursor.
before := arena.cursor;
expect(fixed_arena_alloc(arena, available + 1) == null,
"allocating fixed_arena_available_bytes + 1 (%) succeeded", available + 1);
expect(arena.cursor == before, "a rejected over-request moved the cursor to %", arena.cursor);
// Exactly what it reports must succeed, and must land on the end of the block.
p := fixed_arena_alloc(arena, available);
expect(p != null, "allocating exactly fixed_arena_available_bytes (%) failed", available);
expect(arena.cursor == arena.memory.count,
"allocating everything available left the cursor at % of %", arena.cursor, arena.memory.count);
}
// A zero-byte request must not depend on where the block happens to start. On a block whose
// base is not FIXED_ARENA_DEFAULT_ALIGNMENT-aligned, aligning at exact capacity walks past
// the end -- so without the zero-byte early return this is refused, and the guarantee in
// docs/fixed-arena.md is false for exactly the buffers fixed_arena_init exists to wrap.
// A bootstrapped block comes from the heap already aligned, so it cannot reach this case.
{
backing: [512] u8;
base := backing.data;
while (cast(s64)base) % FIXED_ARENA_DEFAULT_ALIGNMENT != 8 {
base += 1;
}
// A multiple of the alignment, so that the END of the block is misaligned too. That is
// what makes this case discriminating: if the end happened to be aligned, aligning at
// exact capacity would land on it and a zero-byte request would succeed either way.
BLOCK_BYTES :: 256;
block: []u8;
block.data = base;
block.count = BLOCK_BYTES;
misaligned: FixedArena;
ok := fixed_arena_init(*misaligned, block);
expect(ok, "could not initialize an arena over a misaligned block");
if ok {
expect((cast(s64)misaligned.memory.data) % FIXED_ARENA_DEFAULT_ALIGNMENT != 0,
"the test block is aligned after all; this case proves nothing");
expect((cast(s64)(misaligned.memory.data + misaligned.memory.count)) % FIXED_ARENA_DEFAULT_ALIGNMENT != 0,
"the test block ends on an aligned address; this case proves nothing");
// Fill it exactly.
fill_size := fixed_arena_available_bytes(*misaligned);
expect(fixed_arena_alloc(*misaligned, fill_size) != null,
"could not fill the misaligned block with its reported % bytes", fill_size);
expect(misaligned.cursor == misaligned.memory.count,
"the misaligned block is not exactly full: cursor % of %",
misaligned.cursor, misaligned.memory.count);
expect(fixed_arena_alloc(*misaligned, 0) != null,
"a zero-byte allocation was refused by a full block based at %, which makes the result depend on the base address",
misaligned.memory.data);
}
}
// Negative sizes are refused rather than walking the cursor backwards. Regression guard
// for C2 -- the old deferred assert was `cursor <= count`, which passes on a negative
// cursor because it guards the wrong end.
{
expecting_errors("negative allocation sizes");
fixed_arena_reset(arena);
before := arena.cursor;
expect(fixed_arena_alloc(arena, -1) == null, "a -1 byte allocation returned a pointer");
expect(fixed_arena_alloc(arena, -TEST_SIZE) == null, "a large negative allocation returned a pointer");
expect(arena.cursor == before, "a negative allocation moved the cursor to %", arena.cursor);
expect(arena.cursor >= 0, "the cursor went negative");
}
// A size near S64_MAX must fail the capacity check rather than wrap past it. Regression
// guard for C2's overflow half: written as `byte_count > capacity - aligned`, this is
// safe; written as `aligned + byte_count > capacity` it would wrap negative and pass.
{
expecting_errors("an allocation near S64_MAX");
fixed_arena_reset(arena);
before := arena.cursor;
huge := 0x7FFF_FFFF_FFFF_FFFF;
expect(fixed_arena_alloc(arena, huge) == null, "an allocation of S64_MAX bytes succeeded");
expect(arena.cursor == before, "an overflowing allocation moved the cursor to %", arena.cursor);
expect(is_valid(arena), "the arena did not survive an overflowing request");
}
// A null or invalid arena is declined, not asserted on. Regression guard for M5.
{
expecting_errors("allocation against a null and an invalid arena");
expect(fixed_arena_alloc(null, 64) == null, "allocating from a null arena returned a pointer");
broken: FixedArena; // zeroed: no block at all
expect(fixed_arena_alloc(*broken, 64) == null, "allocating from an uninitialized arena returned a pointer");
}
}
// ---------------------------------------------------------------------------
// Alignment: results aligned as addresses, not as offsets
// ---------------------------------------------------------------------------
alignment_tests :: () {
group("alignment");
// Every result out of a bootstrapped arena is aligned.
{
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping alignment tests");
return;
}
defer delete_fixed_arena(arena);
// Odd sizes so the cursor lands off-alignment between allocations.
sizes := s64.[1, 3, 7, 17, 33, 65, 129];
for sizes {
p := fixed_arena_alloc(arena, it);
expect(p != null, "a % byte allocation failed", it);
if p {
expect(is_aligned(p, FIXED_ARENA_DEFAULT_ALIGNMENT),
"a % byte allocation returned %, which is not %-byte aligned", it, p, FIXED_ARENA_DEFAULT_ALIGNMENT);
}
}
}
// The one that matters: a block whose base is NOT 16-aligned. Aligning the offset rather
// than the address would return misaligned pointers here while looking correct above.
// Regression guard for H1 -- an 8-aligned block is exactly what an Arena hands out,
// since ARENA_DEFAULT_ALIGNMENT is 8.
{
backing: [TEST_SIZE] u8;
// Walk the base forward to the next address that is 8-aligned but not 16-aligned.
base := backing.data;
while (cast(s64)base) % 16 != 8 {
base += 1;
}
available := TEST_SIZE - cast(s64)(base - backing.data);
misaligned_block: []u8;
misaligned_block.data = base;
misaligned_block.count = available;
a: FixedArena;
ok := fixed_arena_init(*a, misaligned_block);
expect(ok, "could not initialize an arena over a deliberately misaligned block");
expect((cast(s64)a.memory.data) % FIXED_ARENA_DEFAULT_ALIGNMENT != 0,
"the test block is already aligned; this case proves nothing");
if ok {
for 1..8 {
p := fixed_arena_alloc(*a, it * 5);
expect(p != null, "allocation % failed on the misaligned block", it);
if p {
expect(is_aligned(p, FIXED_ARENA_DEFAULT_ALIGNMENT),
"allocation % returned %, not %-byte aligned, from a block based at %",
it, p, FIXED_ARENA_DEFAULT_ALIGNMENT, a.memory.data);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Resize: in-place growth and shrink, copy fallback, null old_memory
// ---------------------------------------------------------------------------
resize_tests :: () {
group("resize");
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping resize tests");
return;
}
defer delete_fixed_arena(arena);
// Growing the most recent allocation extends it where it sits. Regression guard for M6:
// without this, growing an array to N elements costs O(N^2) bytes of a block that
// cannot grow.
{
fixed_arena_reset(arena);
p := fixed_arena_alloc(arena, 64);
expect(p != null, "the initial 64 byte allocation failed");
if p {
fill(p, 64, 0x5A);
grown := fixed_arena_resize(arena, p, 64, 128);
expect(grown == p, "growing the most recent allocation moved it from % to %", p, grown);
expect(all_bytes_are(grown, 64, 0x5A), "growing in place did not preserve the original bytes");
expect(arena.cursor == cast(s64)(cast(*u8)p - arena.memory.data) + 128,
"cursor is % after growing to 128 bytes in place", arena.cursor);
}
}
// Shrinking the most recent allocation reclaims the difference.
{
fixed_arena_reset(arena);
p := fixed_arena_alloc(arena, 256);
expect(p != null, "the initial 256 byte allocation failed");
if p {
shrunk := fixed_arena_resize(arena, p, 256, 64);
expect(shrunk == p, "shrinking the most recent allocation moved it");
expect(arena.cursor == cast(s64)(cast(*u8)p - arena.memory.data) + 64,
"shrinking did not reclaim: cursor is %", arena.cursor);
}
}
// Resizing anything other than the most recent allocation copies.
{
fixed_arena_reset(arena);
first := fixed_arena_alloc(arena, 64);
second := fixed_arena_alloc(arena, 64);
expect(first != null && second != null, "setup allocations failed");
if first && second {
fill(first, 64, 0x77);
moved := fixed_arena_resize(arena, first, 64, 128);
expect(moved != null, "resizing a buried allocation returned null");
expect(moved != first, "a buried allocation was extended in place, over its neighbour");
expect(all_bytes_are(moved, 64, 0x77), "the copy fallback did not preserve the original bytes");
}
}
// A resize with no previous allocation behaves as an allocation. Regression guard for
// H3: the memcpy used to run unconditionally.
{
fixed_arena_reset(arena);
p := fixed_arena_resize(arena, null, 0, 64);
expect(p != null, "resize from null/0 returned null");
// And a non-zero old_size with a null pointer must not be read from.
q := fixed_arena_resize(arena, null, 32, 64);
expect(q != null, "resize from null with a stale old_size returned null");
}
// A resize that cannot fit returns null rather than a short block.
{
expecting_errors("a resize larger than the block");
fixed_arena_reset(arena);
p := fixed_arena_alloc(arena, 64);
expect(p != null, "the initial allocation failed");
if p {
expect(fixed_arena_resize(arena, p, 64, TEST_SIZE * 2) == null,
"a resize to twice the block size succeeded");
}
}
// Negative and invalid inputs are declined.
{
expecting_errors("negative resize and a null arena");
expect(fixed_arena_resize(arena, null, 0, -1) == null, "a negative resize returned a pointer");
expect(fixed_arena_resize(null, null, 0, 64) == null, "resizing against a null arena returned a pointer");
}
}
// ---------------------------------------------------------------------------
// Reset: fixed_arena_reset, auto_reset, and the header surviving both
// ---------------------------------------------------------------------------
reset_tests :: () {
group("reset");
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping reset tests");
return;
}
defer delete_fixed_arena(arena);
// Reset returns the cursor to the start of usable memory, NOT to zero. Regression guard
// for M1: resetting to zero would let the next allocation overwrite the header.
{
p := fixed_arena_alloc(arena, 512);
expect(p != null, "the setup allocation failed");
fixed_arena_reset(arena);
expect(arena.cursor == FIXED_ARENA_HEADER_BYTES,
"reset put the cursor at %, expected % -- resetting to 0 would hand out the header",
arena.cursor, FIXED_ARENA_HEADER_BYTES);
// The arena survives being reused, which is what proves the header was not clobbered.
q := fixed_arena_alloc(arena, 512);
expect(q != null, "the first allocation after a reset failed");
expect(q == p, "reset did not reuse the block: % then %", p, q);
expect(is_valid(arena), "the arena is no longer valid after a reset-and-reallocate cycle");
expect(arena.memory.count == TEST_SIZE, "the block size was corrupted by a reset cycle: %", arena.memory.count);
}
// The whole block is reusable after a reset, repeatedly.
{
for 1..3 {
fixed_arena_reset(arena);
remaining := fixed_arena_available_bytes(arena);
p := fixed_arena_alloc(arena, remaining);
expect(p != null, "could not refill the block on pass %", it);
}
fixed_arena_reset(arena);
}
// auto_reset restores the cursor at scope exit. Regression guard for M1's other half:
// push_arena alone restores context.allocator, not the cursor.
{
fixed_arena_reset(arena);
outer := arena.cursor;
{
auto_reset(arena);
p := fixed_arena_alloc(arena, 256);
expect(p != null, "the allocation inside an auto_reset scope failed");
expect(arena.cursor > outer, "the cursor did not move inside the scope");
}
expect(arena.cursor == outer, "auto_reset left the cursor at %, expected %", arena.cursor, outer);
}
// Nested auto_reset scopes unwind in order.
{
fixed_arena_reset(arena);
level0 := arena.cursor;
{
auto_reset(arena);
fixed_arena_alloc(arena, 64);
level1 := arena.cursor;
{
auto_reset(arena);
fixed_arena_alloc(arena, 64);
expect(arena.cursor > level1, "the inner scope did not allocate");
}
expect(arena.cursor == level1, "the inner auto_reset restored to % instead of %", arena.cursor, level1);
}
expect(arena.cursor == level0, "the outer auto_reset restored to % instead of %", arena.cursor, level0);
}
// Reset declines an invalid arena rather than writing to it.
{
broken: FixedArena;
fixed_arena_reset(*broken);
expect(broken.cursor == 0, "fixed_arena_reset wrote to an uninitialized arena");
fixed_arena_reset(null);
expect(true, "fixed_arena_reset(null) did not fault");
}
}
// ---------------------------------------------------------------------------
// Allocator interface: the proc's modes, get_allocator, get_capabilities
// ---------------------------------------------------------------------------
allocator_tests :: () {
group("allocator");
arena := bootstrap_fixed_arena(TEST_SIZE);
if !arena {
expect(false, "bootstrap failed; skipping allocator tests");
return;
}
defer delete_fixed_arena(arena);
// push_arena routes context allocations into the arena.
{
fixed_arena_reset(arena);
before := arena.cursor;
{
push_arena(arena);
p := alloc(128);
expect(p != null, "alloc inside a push_arena scope returned null");
expect(arena.cursor > before, "alloc inside a push_arena scope did not come out of the arena");
}
expect(context.allocator.proc != fixed_arena_allocator_proc, "push_arena did not restore context.allocator");
}
// get_allocator produces the same thing push_arena installs. Regression guard for L2.
{
a := get_allocator(arena);
expect(a.proc == fixed_arena_allocator_proc, "get_allocator returned the wrong proc");
expect(a.data == cast(*void)arena, "get_allocator did not bind the arena");
}
// IS_THIS_YOURS answers for live memory only. Regression guard for H4.
{
fixed_arena_reset(arena);
live := fixed_arena_alloc(arena, 64);
expect(live != null, "the setup allocation failed");
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, live, arena) != null,
"IS_THIS_YOURS disowned a live allocation");
last_byte := cast(*u8)live + 63;
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, last_byte, arena) != null,
"IS_THIS_YOURS disowned the last live byte");
// The unallocated tail is not ours: nobody was given it.
tail := arena.memory.data + arena.cursor;
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, tail, arena) == null,
"IS_THIS_YOURS claimed the unallocated tail");
// Neither is the header, which was never handed out.
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, arena.memory.data, arena) == null,
"IS_THIS_YOURS claimed the header region");
stack_value := 0;
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, *stack_value, arena) == null,
"IS_THIS_YOURS claimed a stack address");
// A reset un-owns everything it previously handed out.
fixed_arena_reset(arena);
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, live, arena) == null,
"IS_THIS_YOURS still claims memory released by a reset");
}
// CAPS advertises what the proc actually implements. Regression guard for M2.
{
caps, name := get_capabilities(get_allocator(arena));
expect(caps & .IS_THIS_YOURS == .IS_THIS_YOURS, "CAPS does not advertise IS_THIS_YOURS, but the mode is implemented");
expect(caps & .HINT_I_AM_A_FAST_BUMP_ALLOCATOR == .HINT_I_AM_A_FAST_BUMP_ALLOCATOR,
"CAPS does not advertise the bump-allocator hint");
expect(caps & .FREE == 0, "CAPS advertises FREE, but .FREE is a no-op");
expect(caps & .MULTIPLE_THREADS == 0, "CAPS advertises MULTIPLE_THREADS; nothing here is synchronized");
expect(name.count > 0, "CAPS did not fill in the allocator name, which callers may treat as a hard error");
}
// A null allocator_data must be survivable in every mode, because get_capabilities
// passes null outright: capabilities are a property of the proc, not of any one heap.
// Regression guard for M5.
{
expecting_errors("allocator calls with a null arena");
name: string;
caps := cast,no_check(Allocator_Caps) fixed_arena_allocator_proc(.CAPS, 0, 0, *name, null);
expect(caps & .IS_THIS_YOURS == .IS_THIS_YOURS, "CAPS with a null arena lost the IS_THIS_YOURS bit");
expect(name.count > 0, "CAPS with a null arena did not fill in the allocator name");
stack_value := 0;
expect(fixed_arena_allocator_proc(.IS_THIS_YOURS, 0, 0, *stack_value, null) == null,
"IS_THIS_YOURS claimed memory while holding a null arena");
expect(fixed_arena_allocator_proc(.ALLOCATE, 64, 0, null, null) == null,
"ALLOCATE with a null arena returned a pointer");
expect(fixed_arena_allocator_proc(.RESIZE, 64, 0, null, null) == null,
"RESIZE with a null arena returned a pointer");
}
// FREE is a no-op that returns null rather than falling off the end of the switch.
// Regression guard for M3.
{
fixed_arena_reset(arena);
p := fixed_arena_alloc(arena, 64);
before := arena.cursor;
expect(fixed_arena_allocator_proc(.FREE, 0, 64, p, arena) == null, "FREE returned a pointer");
expect(arena.cursor == before, "FREE moved the cursor");
expect(is_valid(arena), "the arena did not survive a FREE");
}
// The lifecycle modes are answerable and harmless.
{
expect(fixed_arena_allocator_proc(.STARTUP, 0, 0, null, arena) == null, "STARTUP returned a pointer");
expect(fixed_arena_allocator_proc(.SHUTDOWN, 0, 0, null, arena) == null, "SHUTDOWN returned a pointer");
expect(fixed_arena_allocator_proc(.THREAD_START, 0, 0, null, arena) == null, "THREAD_START returned a pointer");
expect(fixed_arena_allocator_proc(.THREAD_STOP, 0, 0, null, arena) == null, "THREAD_STOP returned a pointer");
expect(fixed_arena_allocator_proc(.CREATE_HEAP, 0, 0, null, arena) == null, "CREATE_HEAP returned a pointer");
expect(fixed_arena_allocator_proc(.DESTROY_HEAP, 0, 0, null, arena) == null, "DESTROY_HEAP returned a pointer");
expect(is_valid(arena), "the arena did not survive the lifecycle modes");
}
// A full arena reached through the context returns null rather than corrupting memory.
// This is C1 seen from the outside: the path an ordinary caller actually takes.
{
expecting_errors("exhausting the arena through the allocator interface");
fixed_arena_reset(arena);
push_arena(arena);
// Drain it.
remaining := fixed_arena_available_bytes(arena);
bulk := alloc(remaining);
expect(bulk != null, "could not drain the arena through alloc");
expect(alloc(1) == null, "alloc returned a pointer from a full arena");
expect(is_valid(arena), "the arena is not valid after being exhausted");
expect(arena.cursor <= arena.memory.count,
"the cursor (%) escaped the block (%) -- this is the C1 corruption", arena.cursor, arena.memory.count);
}
}
// ---------------------------------------------------------------------------
// What the numbers mean, so nobody reads more into them than is there:
//
// A "check" is one expect() call reached at runtime, not one distinct property. A check
// inside a loop counts once per iteration, so a loop can contribute more checks than every
// other test in its group put together. The per-group breakdown below makes that visible
// rather than burying it in one headline figure.
//
// Breadth is better measured by the number of expect() call sites in this file, and better
// still by whether a real defect would trip one. Every finding in
// fixed-arena-code-review.html that has an observable behaviour has a call site here that
// would have failed before the fix.
report :: () -> bool {
emit("\n-- results by group --\n");
for 0..group_count-1 {
if group_fails[it] {
emit(" % -- % checks, % FAILED\n", group_names[it], group_checks[it], group_fails[it]);
} else {
emit(" % -- % checks\n", group_names[it], group_checks[it]);
}
}
emit("\n");
if checks_failed == 0 {
emit("ALL FIXED ARENA TESTS PASSED (% checks over % groups)\n", checks_run, group_count);
return true;
}
emit("FIXED ARENA TESTS FAILED (% of % checks over % groups)\n", checks_failed, checks_run, group_count);
return false;
}
#scope_export
// builder null writes to stdout as it goes; pass one to buffer the whole report so a
// caller running several suites at once can print each intact. See src/test/testing.jai.
run_fixed_arena_tests :: (builder: *String_Builder = null) -> bool {
output_builder = builder;
emit("=== fixed_arena.jai test suite ===\n");
emit("size_of(FixedArena) = %, header = % bytes, minimum block = %, alignment = %\n",
size_of(FixedArena), FIXED_ARENA_HEADER_BYTES, FIXED_ARENA_MINIMUM_SIZE, FIXED_ARENA_DEFAULT_ALIGNMENT);
#if RUN_LIFECYCLE_TESTS { lifecycle_tests(); }
#if RUN_ALLOCATION_TESTS { allocation_tests(); }
#if RUN_BOUNDS_TESTS { bounds_tests(); }
#if RUN_ALIGNMENT_TESTS { alignment_tests(); }
#if RUN_RESIZE_TESTS { resize_tests(); }
#if RUN_RESET_TESTS { reset_tests(); }
#if RUN_ALLOCATOR_TESTS { allocator_tests(); }
return report();
}