// 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(); }