mcap/modules/Basic/tests/arena_tests.jai

1726 lines
73 KiB
Plaintext

// Test suite for arena.jai and arena_platform.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 (Arena_State,
// get_arena_state, restore_arena_state) is in reach. It is still exercised through the
// public wrappers on purpose -- arena_release and the auto_* macros -- because the public
// contract is what callers depend on.
//
// Covers every exported symbol of both files. ARENA_DEBUG is Basic's own module parameter
// here, so the suite tests the module as it was actually built.
//
// Groups are toggled with the RUN_* constants below. Each group is #if'd at its call site
// in run_arena_tests(), so a disabled group is not invoked at all.
//
// Several groups deliberately drive failure paths that log. Expected log_error and
// log_warning output is bracketed by "expecting errors" banners -- noise inside a
// banner is the test working, noise outside one is not.
// ---------------------------------------------------------------------------
// Group switches
// ---------------------------------------------------------------------------
RUN_LIFECYCLE_TESTS :: true; // arena_init, bootstrap_arena, arena_delete, is_valid
RUN_INTROSPECTION_TESTS :: true; // arena_start/limit/usage/committed, reserve_size_bytes, next_reserve
RUN_ALLOCATION_TESTS :: true; // arena_alloc happy paths, commit growth
RUN_BOUNDS_TESTS :: true; // negative sizes, exhaustion, address-limit refusal
RUN_ALIGNMENT_TESTS :: true; // arena_set_alignment, push_alignment, Align
RUN_RESET_TESTS :: true; // arena_reset, arena_reset_keeping_memory, arena_release
RUN_SCOPED_TESTS :: true; // auto_reset, auto_release, push_arena, the overwriting variants
RUN_ALLOCATOR_TESTS :: true; // arena_allocator_proc modes, get_allocator, get_capabilities
RUN_SECURE_TESTS :: true; // arena_wipe, arena_secure_overwrite[_starting_from]
RUN_PLATFORM_TESTS :: true; // arena_platform_init, extend_committed_pages, commit/lock/unlock
// Paths whose preconditions are enforced by assert() rather than by a runtime check.
// Running these with ENABLE_ASSERT on will abort the process, which is the assert doing
// its job -- so they are off by default. Turn on only in a build with ENABLE_ASSERT=false,
// to confirm the release-mode fallbacks (log and return) behave.
RUN_ASSERT_TRIP_TESTS :: false;
VERBOSE :: false; // print each arena's details as groups run
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
#scope_file
// 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_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);
}
// A fresh, non-bootstrapped arena backed by its own reservation.
// The Arena struct is POD and, for a non-bootstrapped arena, holds no pointers into
// itself, so returning it by value is safe.
make_arena :: (reserve := Arena_Reserve.Size_2M, pages := ARENA_DEFAULT_COMMIT_PAGE_COUNT) -> Arena {
a: Arena;
success, committed := arena_init(*a, reserve, pages);
expect(success, "arena_init(%, % pages) reported failure", reserve, pages);
expect(committed > 0, "arena_init(%, % pages) committed nothing", reserve, pages);
return a;
}
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;
}
is_aligned :: (pointer: *void, alignment: s64) -> bool {
return (cast(s64) pointer) % alignment == 0;
}
DEFAULT_COMMIT_BYTES :: ARENA_DEFAULT_COMMIT_PAGE_COUNT * PLATFORM_MEMORY_PAGE_SIZE;
// ---------------------------------------------------------------------------
// Lifecycle: arena_init, bootstrap_arena, arena_delete, is_valid
// ---------------------------------------------------------------------------
lifecycle_tests :: () {
group("lifecycle");
// A zeroed Arena is not valid until it is initialized.
{
a: Arena;
expect(!is_valid(*a), "a zeroed Arena reported valid");
expect(a.memory_base == null, "a zeroed Arena has a non-null memory_base");
}
// Initialization establishes every invariant the rest of the module depends on.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
expect(is_valid(*a), "arena is not valid after arena_init");
expect(a.memory_base != null, "memory_base is null after a successful arena_init");
expect(a.reserve == .Size_2M, "reserve was not recorded; got %", a.reserve);
expect(arena_alignment(*a) == ARENA_DEFAULT_ALIGNMENT, "alignment is % not the default %", arena_alignment(*a), ARENA_DEFAULT_ALIGNMENT);
expect(a.current_point == arena_start(*a), "current_point does not start at arena_start");
expect(arena_usage_bytes(*a) == 0, "a fresh arena reports % bytes used", arena_usage_bytes(*a));
expect(arena_committed_bytes(*a) == DEFAULT_COMMIT_BYTES,
"default commit is % bytes, expected %", arena_committed_bytes(*a), DEFAULT_COMMIT_BYTES);
expect(arena_initial_commit_pages(*a) == ARENA_DEFAULT_COMMIT_PAGE_COUNT,
"the initial commit is % pages not %", arena_initial_commit_pages(*a), ARENA_DEFAULT_COMMIT_PAGE_COUNT);
expect(!arena_is_bootstrapped(*a), "a stack-held arena claims to be bootstrapped");
}
// Re-initializing an already-live arena is a no-op that still reports success,
// and must not replace the existing reservation.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
base_before := a.memory_base;
arena_alloc(*a, 128);
used_before := arena_usage_bytes(*a);
success, pages := arena_init(*a, .Size_64M);
expect(success, "re-initializing a live arena reported failure");
expect(a.memory_base == base_before, "re-initializing replaced memory_base");
expect(a.reserve == .Size_2M, "re-initializing changed the reserve class to %", a.reserve);
expect(pages == arena_initial_commit_pages(*a), "re-init reported % pages, field holds %", pages, arena_initial_commit_pages(*a));
expect(arena_usage_bytes(*a) == used_before, "re-initializing discarded % bytes of live allocation", used_before);
}
// A commit request of zero is promoted to the default rather than producing an
// arena with no backing pages. Regression guard for C4.
{
a := make_arena(.Size_2M, 0);
defer arena_delete(*a);
expect(arena_initial_commit_pages(*a) >= 1,
"commit_page_count = 0 recorded % pages; a bootstrapped arena needs at least 1 for its header",
arena_initial_commit_pages(*a));
expect(arena_committed_bytes(*a) >= PLATFORM_MEMORY_PAGE_SIZE,
"commit_page_count = 0 left % bytes committed", arena_committed_bytes(*a));
expect(arena_committed_bytes(*a) == arena_initial_commit_pages(*a) * PLATFORM_MEMORY_PAGE_SIZE,
"recorded page count % disagrees with % bytes actually committed",
arena_initial_commit_pages(*a), arena_committed_bytes(*a));
}
// A negative commit request is clamped to the default. The stored count is a log2, so an
// unclamped negative would reach log2_of_power_of_two rather than becoming a huge page count.
{
expecting_errors("negative commit_page_count");
a := make_arena(.Size_2M, -4);
defer arena_delete(*a);
expect(arena_initial_commit_pages(*a) == ARENA_DEFAULT_COMMIT_PAGE_COUNT,
"negative commit_page_count recorded % pages, expected the default %",
arena_initial_commit_pages(*a), ARENA_DEFAULT_COMMIT_PAGE_COUNT);
expect(arena_committed_bytes(*a) == arena_initial_commit_pages(*a) * PLATFORM_MEMORY_PAGE_SIZE,
"recorded page count disagrees with bytes committed after a negative request");
}
// A count that is not a power of two but does fit its reserve is rounded up, because the
// arena stores the count as a log2. Rounding up rather than down keeps the initial commit at
// least what was asked for, and keeps the stored value exact rather than approximate.
// The .Size_64K case below does not exercise this: 20 pages already exceeds a 64K reserve, so
// it takes the clamp instead and lands on 16 either way.
{
ROUNDED :: 32; // 20 rounded up
a := make_arena(.Size_64M, 20);
defer arena_delete(*a);
expect(arena_initial_commit_pages(*a) == ROUNDED,
"a 20 page request recorded % pages, expected % after rounding up to a power of two",
arena_initial_commit_pages(*a), ROUNDED);
expect(arena_committed_bytes(*a) == ROUNDED * PLATFORM_MEMORY_PAGE_SIZE,
"a 20 page request committed % bytes, expected %",
arena_committed_bytes(*a), ROUNDED * PLATFORM_MEMORY_PAGE_SIZE);
}
// Asking for more initial commit than the reserve class holds clamps to the reserve.
{
expecting_errors("commit larger than the 64K reserve");
a := make_arena(.Size_64K, 20);
defer arena_delete(*a);
expect(is_valid(*a), "an over-committed 64K arena did not initialize");
expect(arena_committed_bytes(*a) <= reserve_size_bytes(*a),
"committed % exceeds the % byte reserve", arena_committed_bytes(*a), reserve_size_bytes(*a));
expect(arena_committed_bytes(*a) == reserve_size_bytes(*a),
"expected the clamp to commit the whole 64K reserve, got %", arena_committed_bytes(*a));
}
// arena_delete releases a non-bootstrapped arena and leaves the struct reusable.
{
a := make_arena(.Size_2M);
arena_delete(*a);
expect(!is_valid(*a), "arena is still valid after arena_delete");
expect(a.memory_base == null, "arena_delete left a non-null memory_base");
// The struct can be initialized again afterwards.
success, _ := arena_init(*a, .Size_2M);
expect(success, "could not re-initialize an arena after deleting it");
expect(is_valid(*a), "re-initialized arena is not valid");
arena_delete(*a);
}
// arena_delete tolerates an arena that was never initialized.
{
a: Arena;
arena_delete(*a);
expect(!is_valid(*a), "deleting an uninitialized arena made it valid");
}
// Bootstrapping puts the header inside the arena's own first page.
{
a := bootstrap_arena(.Size_2M, label = "bootstrap-lifecycle");
expect(a != null, "bootstrap_arena returned null");
if a {
expect(is_valid(a), "bootstrapped arena is not valid");
expect(arena_is_bootstrapped(a), "bootstrapped arena does not carry Is_Bootstrapped");
expect(cast(*void) a == cast(*void) a.memory_base, "the header does not sit at memory_base");
expect(a.current_point == arena_start(a), "a fresh bootstrapped arena is not empty");
expect(arena_usage_bytes(a) == 0, "a fresh bootstrapped arena reports % bytes used", arena_usage_bytes(a));
// Allocating from it must not tread on the header.
p := arena_alloc(a, 64);
expect(p != null, "allocation from a bootstrapped arena returned null");
expect(cast(*u8) p >= arena_start(a), "allocation overlaps the bootstrapped header");
expect(a.memory_base != null, "the header was corrupted by an allocation");
// Nothing may touch `a` after this: the struct lives in the memory being released.
arena_delete(a);
}
}
// Bootstrapping with a zero commit request still yields a usable arena. Regression guard for C4.
{
a := bootstrap_arena(.Size_2M, 0, label = "bootstrap-zero-commit");
expect(a != null, "bootstrap_arena(.Size_2M, 0) returned null");
if a {
expect(arena_initial_commit_pages(a) >= 1, "bootstrapped arena recorded % initial pages", arena_initial_commit_pages(a));
p := arena_alloc(a, 32);
expect(p != null, "could not allocate from a zero-commit bootstrapped arena");
arena_delete(a);
}
}
// The clamp path must produce a working bootstrapped arena, not a failure. Regression guard for H1.
{
expecting_errors("bootstrap commit larger than the 64K reserve");
a := bootstrap_arena(.Size_64K, 20, label = "bootstrap-clamped");
expect(a != null, "bootstrap_arena(.Size_64K, 20) returned null; the clamp should have made this work");
if a {
expect(arena_committed_bytes(a) == reserve_size_bytes(a), "clamped bootstrap committed % of %",
arena_committed_bytes(a), reserve_size_bytes(a));
arena_delete(a);
}
}
}
// ---------------------------------------------------------------------------
// Introspection: layout accessors, reserve table, next_reserve
// ---------------------------------------------------------------------------
introspection_tests :: () {
group("introspection");
// The reserve table and the enum must stay in step. #run asserts this at compile
// time; this checks the values themselves.
{
expect(reserve_size_bytes(Arena_Reserve.Size_64K) == 64 * 1024, "Size_64K is %", reserve_size_bytes(Arena_Reserve.Size_64K));
expect(reserve_size_bytes(Arena_Reserve.Size_2M) == 2 * 1024 * 1024, "Size_2M is %", reserve_size_bytes(Arena_Reserve.Size_2M));
expect(reserve_size_bytes(Arena_Reserve.Size_64M) == 64 * 1024 * 1024, "Size_64M is %", reserve_size_bytes(Arena_Reserve.Size_64M));
expect(reserve_size_bytes(Arena_Reserve.Size_2G) == 2 * 1024 * 1024 * 1024, "Size_2G is %", reserve_size_bytes(Arena_Reserve.Size_2G));
// Monotonically increasing, so next_reserve's linear scan is meaningful.
for i: 1..Arena_Sizes.count-1 {
expect(Arena_Sizes[i] > Arena_Sizes[i-1], "Arena_Sizes is not increasing at index %", i);
}
}
// next_reserve must account for the header, or it names a class that cannot hold
// the request. Regression guard for L2.
{
expect(next_reserve(1024, 8) == .Size_64K, "next_reserve(1024) is %", next_reserve(1024, 8));
// Exactly the size of the smallest class does not fit in it, because the header
// is carved out of the front.
expect(next_reserve(64 * 1024, 8) == .Size_2M,
"next_reserve(64K) is % -- a 64K class cannot hold 64K once the header is taken out",
next_reserve(64 * 1024, 8));
// Comfortably under the limit still fits.
expect(next_reserve(64 * 1024 - size_of(Arena) - 64, 8) == .Size_64K,
"next_reserve(just under 64K) is %", next_reserve(64 * 1024 - size_of(Arena) - 64, 8));
// Beyond the largest class, saturate rather than run off the table.
expect(next_reserve(Arena_Sizes[Arena_Sizes.count-1], 8) == .Size_64T,
"next_reserve(over the largest class) is %", next_reserve(Arena_Sizes[Arena_Sizes.count-1], 8));
}
// Layout accessors agree with each other and with the reserve class.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
expect(arena_start(*a) == Align(a.memory_base + size_of(Arena), ARENA_DEFAULT_ALIGNMENT),
"arena_start does not skip the header");
expect(arena_start(*a) > a.memory_base, "arena_start is not past memory_base");
expect(is_aligned(arena_start(*a), ARENA_DEFAULT_ALIGNMENT), "arena_start is not aligned");
expect(arena_address_limit(*a) == a.memory_base + reserve_size_bytes(*a), "arena_address_limit is wrong");
expect(reserve_size_bytes(*a) == reserve_size_bytes(Arena_Reserve.Size_2M), "the two reserve_size_bytes overloads disagree");
expect(arena_usage_bytes(*a) == 0, "fresh usage is %", arena_usage_bytes(*a));
expect(arena_committed_bytes(*a) == cast(s64)(a.first_uncommitted_page - a.memory_base), "arena_committed_bytes is wrong");
arena_alloc(*a, 1000);
expect(arena_usage_bytes(*a) == cast(s64)(a.current_point - arena_start(*a)), "arena_usage_bytes is wrong after allocating");
expect(arena_usage_bytes(*a) >= 1000, "usage % is under the 1000 bytes allocated", arena_usage_bytes(*a));
expect(arena_committed_bytes(*a) <= reserve_size_bytes(*a), "committed exceeds the reserve");
expect(a.current_point <= a.first_uncommitted_page, "current_point is past the committed region");
expect(a.first_uncommitted_page <= arena_address_limit(*a), "first_uncommitted_page is past the address limit");
}
// The bootstrap flag helpers.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
expect(!arena_is_bootstrapped(*a), "a fresh arena claims to be bootstrapped");
arena_set_bootstrap_flag(*a);
expect(arena_is_bootstrapped(*a), "arena_set_bootstrap_flag did not take");
expect(a.flags & .Is_Bootstrapped == .Is_Bootstrapped, "the flag bit is not set");
// Clear it again so the deferred arena_delete frees the reservation and nulls the
// base, rather than taking the bootstrapped path.
a.flags = .None;
}
// arena_details is a debugging aid; it must survive a null and an uninitialized arena.
{
null_text := arena_details(null);
expect(null_text.count > 0, "arena_details(null) returned an empty string");
blank: Arena;
blank_text := arena_details(*blank);
expect(blank_text.count > 0, "arena_details on an uninitialized arena returned an empty string");
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 4096);
live_text := arena_details(*a);
expect(live_text.count > 0, "arena_details on a live arena returned an empty string");
#if VERBOSE {
emit(" %", null_text);
emit(" %", live_text);
}
}
// The struct footprint is a deliberate design constraint: the header is carved out
// of every arena's first page, so growing it costs usable space in every arena.
// If this fires, it is a decision to make, not a number to update blindly.
//
// Three pointers and six single-byte fields is 30 bytes, rounding to 32 at pointer alignment,
// so two bytes are spare. That is what the log2 fields buy: alignment, page size and the
// initial commit count each fit in a byte. One field wider than a byte would push this to 40.
#if !ARENA_DEBUG {
expect(size_of(Arena) == 32,
"size_of(Arena) is % not 32 -- a field changed width; confirm the footprint cost is intended",
size_of(Arena));
}
}
// ---------------------------------------------------------------------------
// Allocation: arena_alloc happy paths and on-demand commit
// ---------------------------------------------------------------------------
allocation_tests :: () {
group("allocation");
// Basic allocation lands inside the arena and is writable.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := arena_alloc(*a, 256);
expect(p != null, "arena_alloc returned null for a 256 byte request");
expect(cast(*u8) p >= arena_start(*a), "allocation is below arena_start");
expect(cast(*u8) p + 256 <= a.first_uncommitted_page, "allocation extends past the committed region");
fill(p, 256, 0xAB);
expect(all_bytes_are(p, 256, 0xAB), "could not read back what was written to the allocation");
}
// Successive allocations do not overlap and advance monotonically.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZES :: s64.[1, 7, 8, 9, 64, 100, 4096, 3];
previous_end: *u8 = arena_start(*a);
for size: SIZES {
p := cast(*u8) arena_alloc(*a, size);
expect(p != null, "arena_alloc(%) returned null", size);
expect(p >= previous_end, "allocation of % overlaps the previous one", size);
expect(is_aligned(p, arena_alignment(*a)), "allocation of % is not aligned to %", size, arena_alignment(*a));
fill(p, size, 0x5A);
previous_end = p + size;
}
// Every region still holds its own pattern, i.e. nothing was handed out twice.
expect(a.current_point >= previous_end, "current_point is behind the last allocation");
}
// A zero-byte request is legal and does not advance the arena.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 24); // get off the aligned base
before := a.current_point;
p := arena_alloc(*a, 0);
expect(p != null, "a zero-byte allocation returned null");
expect(a.current_point == before || arena_usage_bytes(*a) >= 0, "a zero-byte allocation moved current_point backwards");
expect(a.current_point >= before, "a zero-byte allocation moved current_point backwards");
}
// Crossing the committed boundary commits more pages rather than failing.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
committed_before := arena_committed_bytes(*a);
expect(committed_before == DEFAULT_COMMIT_BYTES, "unexpected starting commit of %", committed_before);
// Ask for more than is currently committed.
size := committed_before + 4096;
p := arena_alloc(*a, size);
expect(p != null, "allocation across the commit boundary returned null");
expect(arena_committed_bytes(*a) > committed_before, "the committed region did not grow");
expect(arena_committed_bytes(*a) % PLATFORM_MEMORY_PAGE_SIZE == 0, "committed size % is not page aligned", arena_committed_bytes(*a));
expect(cast(*u8) p + size <= a.first_uncommitted_page, "the allocation is not fully committed");
// The whole span must be writable, which is the real test of the commit.
fill(p, size, 0xC3);
expect(all_bytes_are(p, size, 0xC3), "the freshly committed span is not readable/writable");
}
// Many small allocations in sequence, spanning several page commits.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
// A stress run is one assertion, not COUNT of them. Checking inside the loop would
// report two thousand passing checks for a single property and drown out the rest of
// the suite in the totals, so failures are counted and reported once.
COUNT :: 2000;
CHUNK :: 64; // a multiple of the default alignment, so the blocks come out contiguous
first: *u8;
null_returns := 0;
for 0..COUNT-1 {
p := cast(*u8) arena_alloc(*a, CHUNK);
if !p { null_returns += 1; continue; }
if it == 0 first = p;
fill(p, CHUNK, cast(u8)(it % 256));
}
expect(null_returns == 0, "% of % stress allocations returned null", null_returns, COUNT);
expect(first != null, "the stress loop never recorded its first allocation");
expect(arena_usage_bytes(*a) >= COUNT * CHUNK, "usage % is under the % bytes handed out", arena_usage_bytes(*a), COUNT * CHUNK);
expect(a.current_point <= a.first_uncommitted_page, "current_point ran past the committed region");
// Every block still holds its own pattern, which is the real property under test:
// a bump allocator must never hand the same bytes out twice, across page commits
// included. CHUNK is a multiple of the alignment, so block N sits at first + N*CHUNK.
if first {
clobbered := 0;
for 0..COUNT-1 {
if !all_bytes_are(first + it * CHUNK, CHUNK, cast(u8)(it % 256)) clobbered += 1;
}
expect(clobbered == 0, "% of % stress blocks were overwritten; allocations overlapped", clobbered, COUNT);
}
}
// The high-water mark tracks allocations made through arena_alloc directly, not
// only those made through the allocator proc. Regression guard for L4.
#if ARENA_DEBUG {
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 5000);
peak := a.high_water_mark;
expect(peak >= 5000, "high_water_mark is % after allocating 5000 bytes directly", peak);
arena_reset(*a);
arena_alloc(*a, 100);
expect(a.high_water_mark == peak, "high_water_mark fell to % after a reset; it should hold the peak", a.high_water_mark);
}
}
// ---------------------------------------------------------------------------
// Bounds: refusals that must return null without corrupting the arena
// ---------------------------------------------------------------------------
bounds_tests :: () {
group("bounds");
// A negative size is refused and leaves the arena untouched. Regression guard for M8.
{
expecting_errors("negative allocation size");
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 128);
point_before := a.current_point;
usage_before := arena_usage_bytes(*a);
p := arena_alloc(*a, -1);
expect(p == null, "a negative allocation returned a pointer");
expect(a.current_point == point_before, "a negative allocation moved current_point");
expect(arena_usage_bytes(*a) == usage_before, "a negative allocation changed usage");
p2 := arena_alloc(*a, -1024 * 1024);
expect(p2 == null, "a large negative allocation returned a pointer");
expect(a.current_point == point_before, "a large negative allocation moved current_point");
// The arena still works afterwards.
p3 := arena_alloc(*a, 64);
expect(p3 != null, "the arena stopped working after refusing a negative size");
}
// A request larger than the whole reserve is refused. Regression guard for C3.
{
expecting_errors("allocation larger than the reserve");
a := make_arena(.Size_64K);
defer arena_delete(*a);
point_before := a.current_point;
p := arena_alloc(*a, reserve_size_bytes(*a) + 1);
expect(p == null, "an over-reserve allocation returned a pointer");
expect(a.current_point == point_before, "an over-reserve allocation moved current_point");
expect(a.current_point <= arena_address_limit(*a), "current_point escaped the reservation");
}
// A size near S64_MAX must not wrap the pointer arithmetic into something that
// looks in range. Regression guard for M8.
{
expecting_errors("absurd allocation size");
a := make_arena(.Size_2M);
defer arena_delete(*a);
point_before := a.current_point;
p := arena_alloc(*a, 0x7FFF_FFFF_FFFF_FFFF);
expect(p == null, "an S64_MAX allocation returned a pointer");
expect(a.current_point == point_before, "an S64_MAX allocation moved current_point");
p2 := arena_alloc(*a, 0x4000_0000_0000_0000);
expect(p2 == null, "a 2^62 allocation returned a pointer");
expect(a.current_point == point_before, "a 2^62 allocation moved current_point");
}
// Filling the arena exactly, then failing to exceed it.
{
expecting_errors("exhausting a 64K arena");
a := make_arena(.Size_64K);
defer arena_delete(*a);
remaining := cast(s64)(arena_address_limit(*a) - a.current_point);
expect(remaining > 0, "no space available in a fresh 64K arena");
p := arena_alloc(*a, remaining);
expect(p != null, "could not allocate the exact remaining % bytes", remaining);
expect(a.current_point == arena_address_limit(*a), "current_point is not at the limit after filling the arena");
q := arena_alloc(*a, 1);
expect(q == null, "allocated one byte past a full arena");
expect(a.current_point == arena_address_limit(*a), "current_point moved past the limit");
}
// A refusal must not leave the arena in a state where later allocations misbehave.
{
expecting_errors("refusal followed by recovery");
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, -5);
arena_alloc(*a, reserve_size_bytes(*a) * 2);
p := cast(*u8) arena_alloc(*a, 4096);
expect(p != null, "the arena is unusable after two refusals");
expect(p >= arena_start(*a) && p + 4096 <= a.first_uncommitted_page, "post-refusal allocation is out of bounds");
fill(p, 4096, 0x11);
expect(all_bytes_are(p, 4096, 0x11), "post-refusal allocation is not writable");
}
}
// ---------------------------------------------------------------------------
// Alignment: arena_set_alignment, push_alignment, Align
// ---------------------------------------------------------------------------
alignment_tests :: () {
group("alignment");
// Align itself, on integers and pointers.
{
expect(Align(0, 8) == 0, "Align(0, 8) is %", Align(0, 8));
expect(Align(1, 8) == 8, "Align(1, 8) is %", Align(1, 8));
expect(Align(8, 8) == 8, "Align(8, 8) is %", Align(8, 8));
expect(Align(9, 8) == 16, "Align(9, 8) is %", Align(9, 8));
expect(Align(4095, PLATFORM_MEMORY_PAGE_SIZE) == 4096, "Align(4095, page) is %", Align(4095, PLATFORM_MEMORY_PAGE_SIZE));
expect(Align(4096, PLATFORM_MEMORY_PAGE_SIZE) == 4096, "Align(4096, page) is %", Align(4096, PLATFORM_MEMORY_PAGE_SIZE));
expect(Align(4097, PLATFORM_MEMORY_PAGE_SIZE) == 8192, "Align(4097, page) is %", Align(4097, PLATFORM_MEMORY_PAGE_SIZE));
}
// Setting a valid alignment changes what subsequent allocations return.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
ALIGNMENTS :: s64.[8, 16, 32, 64, 256, 4096];
for wanted: ALIGNMENTS {
arena_set_alignment(*a, wanted);
expect(arena_alignment(*a) == wanted, "alignment is % after asking for %", arena_alignment(*a), wanted);
// Push the cursor to an awkward offset first, so the aligner has work to do.
arena_alloc(*a, 1);
p := arena_alloc(*a, 16);
expect(p != null, "allocation returned null at alignment %", wanted);
expect(is_aligned(p, wanted), "allocation % is not aligned to %", p, wanted);
}
}
// Invalid alignments are refused and leave the existing value alone.
// These are runtime-checked, but the procedure also asserts first, so they only
// run in a build with asserts off.
#if RUN_ASSERT_TRIP_TESTS {
expecting_errors("invalid alignments");
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_set_alignment(*a, 16);
expect(arena_alignment(*a) == 16, "setup failed; alignment is %", arena_alignment(*a));
arena_set_alignment(*a, 0);
expect(arena_alignment(*a) == 16, "an alignment of 0 was accepted; alignment is now %", arena_alignment(*a));
arena_set_alignment(*a, -8);
expect(arena_alignment(*a) == 16, "a negative alignment was accepted; alignment is now %", arena_alignment(*a));
arena_set_alignment(*a, 24);
expect(arena_alignment(*a) == 16, "a non-power-of-two alignment was accepted; alignment is now %", arena_alignment(*a));
// The bound is the reservation, not a field width: an alignment wider than the whole
// reservation would make every allocation fail the address-limit check, and anything at or
// below it is representable now that the field holds a log2.
arena_set_alignment(*a, 4 * 1024 * 1024);
expect(arena_alignment(*a) == reserve_size_bytes(*a),
"an alignment wider than the reservation gave %, expected the % byte reservation",
arena_alignment(*a), reserve_size_bytes(*a));
}
// What the old u16 field could not express. 65536 used to be clamped to 32768, and a 2 MB
// alignment was not representable at all; both are exact now.
{
a := make_arena(.Size_64M);
defer arena_delete(*a);
for wanted: s64.[65536, 2 * 1024 * 1024] {
arena_set_alignment(*a, wanted);
expect(arena_alignment(*a) == wanted, "alignment is % after asking for %", arena_alignment(*a), wanted);
arena_alloc(*a, 1);
p := arena_alloc(*a, 16);
expect(p != null, "allocation returned null at alignment %", wanted);
expect(is_aligned(p, wanted), "allocation % is not aligned to %", p, wanted);
}
}
// push_alignment applies for the enclosing scope and restores afterwards.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_set_alignment(*a, 8);
before := arena_alignment(*a);
{
push_alignment(*a, 64);
expect(arena_alignment(*a) == 64, "push_alignment did not apply; alignment is %", arena_alignment(*a));
arena_alloc(*a, 1);
p := arena_alloc(*a, 8);
expect(is_aligned(p, 64), "allocation inside push_alignment is not 64 aligned");
}
expect(arena_alignment(*a) == before, "push_alignment did not restore; alignment is % not %", arena_alignment(*a), before);
arena_alloc(*a, 1);
q := arena_alloc(*a, 8);
expect(is_aligned(q, before), "allocation after push_alignment is not aligned to the restored %", before);
}
// Nested pushes unwind in order.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_set_alignment(*a, 8);
{
push_alignment(*a, 32);
{
push_alignment(*a, 256);
expect(arena_alignment(*a) == 256, "inner push_alignment gave %", arena_alignment(*a));
}
expect(arena_alignment(*a) == 32, "after the inner scope alignment is % not 32", arena_alignment(*a));
}
expect(arena_alignment(*a) == 8, "after both scopes alignment is % not 8", arena_alignment(*a));
}
}
// ---------------------------------------------------------------------------
// Reset family: arena_reset, arena_reset_keeping_memory, arena_release
// ---------------------------------------------------------------------------
reset_tests :: () {
group("reset");
// arena_reset rewinds the cursor and keeps the pages committed.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 200 * 1024); // force the committed region to grow
grown := arena_committed_bytes(*a);
expect(grown > DEFAULT_COMMIT_BYTES, "the committed region did not grow; it is %", grown);
arena_reset(*a);
expect(arena_usage_bytes(*a) == 0, "usage is % after arena_reset", arena_usage_bytes(*a));
expect(a.current_point == arena_start(*a), "current_point is not at arena_start after arena_reset");
expect(arena_committed_bytes(*a) == grown, "arena_reset decommitted pages; committed went from % to %", grown, arena_committed_bytes(*a));
// Reusing the arena after a reset hands back the same base address.
p := arena_alloc(*a, 64);
expect(cast(*u8) p == arena_start(*a), "the first allocation after a reset is not at arena_start");
}
// arena_reset_keeping_memory behaves the same way.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 200 * 1024);
grown := arena_committed_bytes(*a);
arena_reset_keeping_memory(*a);
expect(arena_usage_bytes(*a) == 0, "usage is % after arena_reset_keeping_memory", arena_usage_bytes(*a));
expect(arena_committed_bytes(*a) == grown, "arena_reset_keeping_memory decommitted pages");
}
// arena_release rewinds and gives the pages back, down to the initial commit.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 400 * 1024);
expect(arena_committed_bytes(*a) > DEFAULT_COMMIT_BYTES, "the committed region did not grow before release");
arena_release(*a);
expect(arena_usage_bytes(*a) == 0, "usage is % after arena_release", arena_usage_bytes(*a));
expect(arena_committed_bytes(*a) == DEFAULT_COMMIT_BYTES,
"arena_release left % bytes committed, expected the initial %", arena_committed_bytes(*a), DEFAULT_COMMIT_BYTES);
expect(a.current_point <= a.first_uncommitted_page, "current_point is past the committed region after release");
// The arena must still work, re-committing on demand.
p := arena_alloc(*a, 300 * 1024);
expect(p != null, "could not allocate after arena_release");
fill(p, 300 * 1024, 0x77);
expect(all_bytes_are(p, 300 * 1024, 0x77), "memory re-committed after release is not writable");
}
// Releasing a bootstrapped arena must never decommit the page holding its header.
// Regression guard for C4.
{
a := bootstrap_arena(.Size_2M, label = "release-keeps-header");
expect(a != null, "bootstrap_arena returned null");
if a {
arena_alloc(a, 400 * 1024);
arena_release(a);
// Reading these fields at all is the test: the header page must still be mapped.
expect(is_valid(a), "the header did not survive arena_release");
expect(arena_committed_bytes(a) >= PLATFORM_MEMORY_PAGE_SIZE, "release left % bytes committed", arena_committed_bytes(a));
expect(arena_usage_bytes(a) == 0, "usage is % after release", arena_usage_bytes(a));
p := arena_alloc(a, 64);
expect(p != null, "a bootstrapped arena is unusable after arena_release");
arena_delete(a);
}
}
// arena_reset_overwriting_memory zeroes what it rewinds past.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 8192;
p := arena_alloc(*a, SIZE);
fill(p, SIZE, 0xDD);
expect(all_bytes_are(p, SIZE, 0xDD), "setup failed; the pattern did not stick");
arena_reset_overwriting_memory(*a);
expect(arena_usage_bytes(*a) == 0, "usage is % after the overwriting reset", arena_usage_bytes(*a));
expect(all_bytes_are(p, SIZE, 0), "the overwriting reset left % bytes unwiped -- it did nothing", SIZE);
}
// The reset family is a no-op on an uninitialized arena rather than a crash.
{
blank: Arena;
arena_reset(*blank);
arena_reset_keeping_memory(*blank);
arena_release(*blank);
arena_reset_overwriting_memory(*blank);
expect(!is_valid(*blank), "resetting an uninitialized arena made it valid");
}
}
// ---------------------------------------------------------------------------
// Scoped helpers: auto_reset, auto_release, push_arena, overwriting variants
// ---------------------------------------------------------------------------
scoped_tests :: () {
group("scoped");
// auto_reset restores the cursor at scope exit and keeps the pages.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 1024);
usage_before := arena_usage_bytes(*a);
point_before := a.current_point;
{
auto_reset(*a);
arena_alloc(*a, 200 * 1024);
expect(arena_usage_bytes(*a) > usage_before, "the inner allocation did not happen");
}
expect(a.current_point == point_before, "auto_reset did not restore current_point");
expect(arena_usage_bytes(*a) == usage_before, "usage is % after auto_reset, expected %", arena_usage_bytes(*a), usage_before);
expect(arena_committed_bytes(*a) > DEFAULT_COMMIT_BYTES, "auto_reset gave pages back; it should keep them");
}
// auto_release restores the cursor and gives the pages back.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
arena_alloc(*a, 512);
point_before := a.current_point;
{
auto_release(*a);
arena_alloc(*a, 400 * 1024);
expect(arena_committed_bytes(*a) > DEFAULT_COMMIT_BYTES, "the inner allocation did not grow the commit");
}
expect(a.current_point == point_before, "auto_release did not restore current_point");
expect(arena_committed_bytes(*a) <= DEFAULT_COMMIT_BYTES + PLATFORM_MEMORY_PAGE_SIZE,
"auto_release left % bytes committed", arena_committed_bytes(*a));
expect(a.current_point <= a.first_uncommitted_page, "current_point is past the committed region after auto_release");
p := arena_alloc(*a, 64);
expect(p != null, "the arena is unusable after auto_release");
}
// The overwriting variants must actually overwrite. Regression guard for H4:
// a length/end-pointer mix-up made these silently do nothing.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 4096;
inner: *void;
{
auto_reset_overwriting_memory(*a);
inner = arena_alloc(*a, SIZE);
fill(inner, SIZE, 0xEE);
expect(all_bytes_are(inner, SIZE, 0xEE), "setup failed inside the scope");
}
expect(all_bytes_are(inner, SIZE, 0),
"auto_reset_overwriting_memory left the region unwiped -- the wipe was skipped entirely");
expect(arena_usage_bytes(*a) == 0, "usage is % after the scope", arena_usage_bytes(*a));
}
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 4096;
inner: *void;
{
auto_release_overwriting_memory(*a);
inner = arena_alloc(*a, SIZE);
fill(inner, SIZE, 0xEE);
}
// The pages stay mapped down to the initial commit, and 4096 bytes sit well inside
// that, so the wiped region is still readable.
expect(all_bytes_are(inner, SIZE, 0), "auto_release_overwriting_memory left the region unwiped");
expect(arena_committed_bytes(*a) == DEFAULT_COMMIT_BYTES, "committed is % after the scope", arena_committed_bytes(*a));
}
// push_arena swaps context.allocator for the scope and restores it afterwards.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
outer := context.allocator;
{
push_arena(*a);
expect(context.allocator.proc == arena_allocator_proc, "push_arena did not install the arena allocator");
expect(context.allocator.data == cast(*void) *a, "push_arena installed the wrong allocator_data");
p := New(u64);
expect(p != null, "New through a pushed arena returned null");
expect(cast(*u8) p >= arena_start(*a) && cast(*u8) p < a.current_point,
"New through a pushed arena did not allocate from the arena");
numbers := NewArray(64, s32);
expect(numbers.count == 64, "NewArray gave % elements", numbers.count);
expect(cast(*u8) numbers.data >= arena_start(*a), "NewArray did not allocate from the arena");
numbers[0] = 11;
numbers[63] = 22;
expect(numbers[0] == 11 && numbers[63] == 22, "arena-backed array memory is not writable");
}
expect(context.allocator.proc == outer.proc, "push_arena did not restore context.allocator.proc");
expect(context.allocator.data == outer.data, "push_arena did not restore context.allocator.data");
}
// A scoped mark taken inside a pushed allocator still unwinds correctly.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
point_before := a.current_point;
{
push_arena(*a);
auto_reset(*a);
s := NewArray(1024, u8);
expect(s.count == 1024, "allocation inside the combined scope failed");
}
expect(a.current_point == point_before, "the combined push_arena + auto_reset scope did not unwind");
}
}
// ---------------------------------------------------------------------------
// Allocator interface: arena_allocator_proc, get_allocator, capabilities
// ---------------------------------------------------------------------------
allocator_tests :: () {
group("allocator");
// get_allocator wires the proc to the arena.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
allocator := get_allocator(*a);
expect(allocator.proc == arena_allocator_proc, "get_allocator returned the wrong proc");
expect(allocator.data == cast(*void) *a, "get_allocator returned the wrong data pointer");
}
// ALLOCATE goes through arena_alloc.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := arena_allocator_proc(.ALLOCATE, 512, 0, null, *a);
expect(p != null, "ALLOCATE returned null");
expect(cast(*u8) p >= arena_start(*a) && cast(*u8) p < a.current_point, "ALLOCATE returned a pointer outside the arena");
fill(p, 512, 0x42);
expect(all_bytes_are(p, 512, 0x42), "memory from ALLOCATE is not writable");
}
// RESIZE allocates fresh memory and copies the old contents across.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
OLD :: 128;
NEW :: 512;
old_memory := arena_allocator_proc(.ALLOCATE, OLD, 0, null, *a);
fill(old_memory, OLD, 0x9C);
new_memory := arena_allocator_proc(.RESIZE, NEW, OLD, old_memory, *a);
expect(new_memory != null, "RESIZE returned null");
expect(new_memory != old_memory, "RESIZE returned the same block; this allocator never resizes in place");
expect(all_bytes_are(new_memory, OLD, 0x9C), "RESIZE did not copy the old contents");
// Shrinking copies only the new size, and must not read past the old block.
smaller := arena_allocator_proc(.RESIZE, 64, NEW, new_memory, *a);
expect(smaller != null, "a shrinking RESIZE returned null");
expect(all_bytes_are(smaller, 64, 0x9C), "a shrinking RESIZE did not copy the leading bytes");
}
// FREE is accepted and does nothing.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := arena_allocator_proc(.ALLOCATE, 256, 0, null, *a);
point_before := a.current_point;
result := arena_allocator_proc(.FREE, 0, 256, p, *a);
expect(result == null, "FREE returned something other than null");
expect(a.current_point == point_before, "FREE moved current_point");
}
// The lifecycle and heap modes are accepted and return null.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
expect(arena_allocator_proc(.STARTUP, 0, 0, null, *a) == null, "STARTUP returned non-null");
expect(arena_allocator_proc(.SHUTDOWN, 0, 0, null, *a) == null, "SHUTDOWN returned non-null");
expect(arena_allocator_proc(.THREAD_START, 0, 0, null, *a) == null, "THREAD_START returned non-null");
expect(arena_allocator_proc(.THREAD_STOP, 0, 0, null, *a) == null, "THREAD_STOP returned non-null");
expect(arena_allocator_proc(.CREATE_HEAP, 0, 0, null, *a) == null, "CREATE_HEAP returned non-null");
expect(arena_allocator_proc(.DESTROY_HEAP, 0, 0, null, *a) == null, "DESTROY_HEAP returned non-null");
expect(is_valid(*a), "a lifecycle mode disturbed the arena");
}
// IS_THIS_YOURS answers for live memory only. Regression guard for M2.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
live := arena_alloc(*a, 256);
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, live, *a) != null, "IS_THIS_YOURS disowned a live allocation");
last_byte := cast(*u8) live + 255;
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, last_byte, *a) != null, "IS_THIS_YOURS disowned the last live byte");
// Committed but not handed out: inside the pages, past current_point.
slack := cast(*void) a.current_point;
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, slack, *a) == null,
"IS_THIS_YOURS claimed committed-but-unallocated slack");
// The header is not an allocation.
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, cast(*void) a.memory_base, *a) == null,
"IS_THIS_YOURS claimed the header region");
// Memory belonging to somebody else.
stack_value := 0;
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, *stack_value, *a) == null, "IS_THIS_YOURS claimed a stack address");
// After a reset the memory is no longer live.
arena_reset(*a);
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, live, *a) == null, "IS_THIS_YOURS still claims memory released by a reset");
}
// CAPS advertises what the proc actually implements. Regression guard for M1.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
caps, name := get_capabilities(get_allocator(*a));
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 & .HINT_I_AM_PER_FRAME_TEMPORARY_STORAGE == 0,
"CAPS claims to be per-frame temporary storage; this arena is not reset every frame");
expect(caps & .FREE == 0, "CAPS advertises FREE, but .FREE is ignored");
expect(caps & .ACTUALLY_RESIZE == 0, "CAPS advertises ACTUALLY_RESIZE, but RESIZE always reallocates");
expect(caps & .MULTIPLE_THREADS == 0, "CAPS advertises MULTIPLE_THREADS; nothing here is synchronized");
expect(caps & .CREATE_HEAP == 0, "CAPS advertises CREATE_HEAP, but the mode returns null");
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. The allocator documentation
// asks for this so the proc can be pushed into a context with nothing else available,
// and get_capabilities relies on it outright: it passes null for a CAPS query, because
// capabilities are a property of the proc rather than of any one heap. Regression
// guard for L8, which was an assert ahead of the mode switch that aborted on all of it.
{
expecting_errors("allocator calls with a null arena");
// CAPS is answerable with no heap at all, and must still fill in the name.
name: string;
caps := cast,no_check(Allocator_Caps) 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");
// Nothing belongs to a heap that is not there.
stack_value := 0;
expect(arena_allocator_proc(.IS_THIS_YOURS, 0, 0, *stack_value, null) == null,
"IS_THIS_YOURS claimed memory while holding a null arena");
// The allocating modes must decline rather than dereference.
expect(arena_allocator_proc(.ALLOCATE, 64, 0, null, null) == null, "ALLOCATE with a null arena returned a pointer");
expect(arena_allocator_proc(.RESIZE, 128, 64, null, null) == null, "RESIZE with a null arena returned a pointer");
// And the modes that do nothing anyway keep doing nothing.
expect(arena_allocator_proc(.FREE, 0, 0, null, null) == null, "FREE with a null arena returned non-null");
expect(arena_allocator_proc(.STARTUP, 0, 0, null, null) == null, "STARTUP with a null arena returned non-null");
expect(arena_allocator_proc(.SHUTDOWN, 0, 0, null, null) == null, "SHUTDOWN with a null arena returned non-null");
expect(arena_allocator_proc(.THREAD_START, 0, 0, null, null) == null, "THREAD_START with a null arena returned non-null");
expect(arena_allocator_proc(.THREAD_STOP, 0, 0, null, null) == null, "THREAD_STOP with a null arena returned non-null");
expect(arena_allocator_proc(.CREATE_HEAP, 0, 0, null, null) == null, "CREATE_HEAP with a null arena returned non-null");
expect(arena_allocator_proc(.DESTROY_HEAP, 0, 0, null, null) == null, "DESTROY_HEAP with a null arena returned non-null");
}
// arena_alloc itself declines a null arena, which is where the guard actually lives.
{
expecting_errors("arena_alloc with a null arena");
expect(arena_alloc(null, 64) == null, "arena_alloc(null) returned a pointer");
expect(arena_alloc(null, 0) == null, "arena_alloc(null, 0) returned a pointer");
}
// Allocation failure must surface as null through the allocator interface too,
// not as a pointer into unmapped space. Regression guard for C3.
{
expecting_errors("allocator-interface refusal");
a := make_arena(.Size_64K);
defer arena_delete(*a);
p := arena_allocator_proc(.ALLOCATE, reserve_size_bytes(*a) * 4, 0, null, *a);
expect(p == null, "the allocator proc returned a pointer for a request larger than the reserve");
}
}
// ---------------------------------------------------------------------------
// Secure overwrite: arena_wipe and the arena_secure_overwrite family
// ---------------------------------------------------------------------------
secure_tests :: () {
group("secure");
// arena_wipe clears the half-open interval it is given.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 1024;
p := cast(*u8) arena_alloc(*a, SIZE);
fill(p, SIZE, 0xF0);
arena_wipe(p, p + 256);
expect(all_bytes_are(p, 256, 0), "arena_wipe did not clear the requested range");
expect(all_bytes_are(p + 256, SIZE - 256, 0xF0), "arena_wipe cleared past the end pointer");
}
// An inverted or empty interval is a no-op, not a wipe of the whole address space.
// This is the guard that keeps a negative count away from the rep stosb. H4.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 512;
p := cast(*u8) arena_alloc(*a, SIZE);
fill(p, SIZE, 0x3C);
arena_wipe(p + 128, p); // end < start
expect(all_bytes_are(p, SIZE, 0x3C), "an inverted arena_wipe interval modified memory");
arena_wipe(p, p); // empty
expect(all_bytes_are(p, SIZE, 0x3C), "an empty arena_wipe interval modified memory");
}
// arena_secure_overwrite(.Current_Point) clears everything handed out so far.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 2048;
p := cast(*u8) arena_alloc(*a, SIZE);
fill(p, SIZE, 0xA5);
tail := a.current_point; // first untouched byte
fill(tail, 64, 0x5A); // committed slack, must survive
arena_secure_overwrite(*a, .Current_Point);
expect(all_bytes_are(p, SIZE, 0), "arena_secure_overwrite(.Current_Point) left live memory unwiped");
expect(all_bytes_are(tail, 64, 0x5A), "arena_secure_overwrite(.Current_Point) wiped past current_point");
// The cursor is not moved by a wipe; only the bytes change.
expect(a.current_point == tail, "arena_secure_overwrite moved current_point");
}
// arena_secure_overwrite(.First_Uncommitted_Page) clears the committed slack too.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := cast(*u8) arena_alloc(*a, 1024);
fill(p, 1024, 0xA5);
slack := a.current_point;
slack_count := cast(s64)(a.first_uncommitted_page - slack);
expect(slack_count > 0, "no committed slack available for this test");
fill(slack, slack_count, 0x5A);
arena_secure_overwrite(*a, .First_Uncommitted_Page);
expect(all_bytes_are(p, 1024, 0), "the live region was not wiped");
expect(all_bytes_are(slack, slack_count, 0), "the committed slack was not wiped");
}
// arena_secure_overwrite_starting_from clears from an interior point onwards.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
SIZE :: 4096;
p := cast(*u8) arena_alloc(*a, SIZE);
fill(p, SIZE, 0xC7);
arena_secure_overwrite_starting_from(*a, p + 1024);
expect(all_bytes_are(p, 1024, 0xC7), "wiping from an interior point cleared memory before it");
expect(all_bytes_are(p + 1024, SIZE - 1024, 0), "wiping from an interior point did not clear to the end");
}
// A start pointer past the committed region is refused, rather than producing a
// negative count. Regression guard for H4.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := cast(*u8) arena_alloc(*a, 256);
fill(p, 256, 0x6B);
arena_secure_overwrite_starting_from(*a, a.first_uncommitted_page + PLATFORM_MEMORY_PAGE_SIZE);
expect(all_bytes_are(p, 256, 0x6B), "an out-of-range start pointer still wiped memory");
arena_secure_overwrite_starting_from(*a, a.first_uncommitted_page);
expect(all_bytes_are(p, 256, 0x6B), "a start pointer at the committed end still wiped memory");
}
// Wiping an arena with less committed than the header occupies must not underflow.
// Reachable via .First_Uncommitted_Page when almost nothing is committed. H4.
{
a := make_arena(.Size_2M, 0);
defer arena_delete(*a);
// Whatever the commit works out to, this must return without wiping wildly.
arena_secure_overwrite(*a, .First_Uncommitted_Page);
expect(is_valid(*a), "the arena did not survive a wipe to the committed end");
p := arena_alloc(*a, 64);
expect(p != null, "the arena is unusable after the wipe");
}
}
// ---------------------------------------------------------------------------
// Platform layer: reserve, commit, decommit, lock
// ---------------------------------------------------------------------------
platform_tests :: () {
group("platform");
// arena_platform_init reserves address space and records the class.
{
a: Arena;
ok := arena_platform_init(*a, .Size_2M);
expect(ok, "arena_platform_init reported failure for a 2M reservation");
expect(a.memory_base != null, "arena_platform_init succeeded but memory_base is null");
expect(a.reserve == .Size_2M, "arena_platform_init recorded reserve %", a.reserve);
// The postcondition the rest of the module relies on: memory_base is null if and
// only if the reservation failed. Regression guard for C2.
expect(ok == (a.memory_base != null), "the memory_base postcondition does not hold");
// Reserved space is not yet committed; the caller drives that separately. Checked as
// committed bytes rather than a null pointer because arena_platform_init now establishes
// the commit pointer: memory_base for a normal reservation, and the top of the reservation
// for a fully committed large-page one.
expect(arena_committed_bytes(*a) == 0, "arena_platform_init committed pages on its own");
arena_delete(*a);
}
// Reservations at several classes, including the very large ones.
{
CLASSES :: Arena_Reserve.[.Size_64K, .Size_2M, .Size_64M, .Size_2G];
for class: CLASSES {
a: Arena;
ok := arena_platform_init(*a, class);
expect(ok, "could not reserve %", class);
if ok {
expect(reserve_size_bytes(*a) == reserve_size_bytes(class), "reserve size mismatch for %", class);
arena_delete(*a);
}
}
}
// extend_committed_pages grows the committed region by whole pages.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
before := a.first_uncommitted_page;
ok := extend_committed_pages(*a, before + 1);
expect(ok, "extend_committed_pages reported failure");
expect(a.first_uncommitted_page == before + PLATFORM_MEMORY_PAGE_SIZE,
"a one byte extension committed % bytes", cast(s64)(a.first_uncommitted_page - before));
// The newly committed page is writable.
fill(before, PLATFORM_MEMORY_PAGE_SIZE, 0x2D);
expect(all_bytes_are(before, PLATFORM_MEMORY_PAGE_SIZE, 0x2D), "the newly committed page is not writable");
}
// An extension to a point already committed is a no-op that reports success.
// Regression guard for the delta <= 0 case.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
before := a.first_uncommitted_page;
expect(extend_committed_pages(*a, before), "extending to the current boundary reported failure");
expect(a.first_uncommitted_page == before, "extending to the current boundary moved the boundary");
expect(extend_committed_pages(*a, a.memory_base), "extending backwards reported failure");
expect(a.first_uncommitted_page == before, "extending backwards moved the boundary");
expect(extend_committed_pages(*a, before - PLATFORM_MEMORY_PAGE_SIZE), "extending below the boundary reported failure");
expect(a.first_uncommitted_page == before, "extending below the boundary moved the boundary");
}
// arena_commit_first_pages reports the bytes actually committed, page aligned.
{
a: Arena;
ok := arena_platform_init(*a, .Size_2M);
expect(ok, "setup reservation failed");
if ok {
a.first_uncommitted_page = a.memory_base;
arena_set_alignment(*a, ARENA_DEFAULT_ALIGNMENT);
committed := arena_commit_first_pages(*a, 3 * PLATFORM_MEMORY_PAGE_SIZE);
expect(committed == 3 * PLATFORM_MEMORY_PAGE_SIZE, "committing 3 pages reported % bytes", committed);
expect(committed % PLATFORM_MEMORY_PAGE_SIZE == 0, "the reported commit % is not page aligned", committed);
expect(committed == arena_committed_bytes(*a), "the report disagrees with first_uncommitted_page");
arena_delete(*a);
}
}
// A sub-page request rounds up to a whole page.
{
a: Arena;
if arena_platform_init(*a, .Size_2M) {
a.first_uncommitted_page = a.memory_base;
arena_set_alignment(*a, ARENA_DEFAULT_ALIGNMENT);
committed := arena_commit_first_pages(*a, 100);
expect(committed == PLATFORM_MEMORY_PAGE_SIZE, "a 100 byte commit request reported % bytes", committed);
arena_delete(*a);
}
}
// Zero and oversized requests are clamped rather than refused or overshot.
{
expecting_errors("zero and oversized commit requests");
a: Arena;
if arena_platform_init(*a, .Size_2M) {
a.first_uncommitted_page = a.memory_base;
arena_set_alignment(*a, ARENA_DEFAULT_ALIGNMENT);
committed := arena_commit_first_pages(*a, 0);
expect(committed >= PLATFORM_MEMORY_PAGE_SIZE, "a zero commit request reported % bytes", committed);
arena_delete(*a);
}
b: Arena;
if arena_platform_init(*b, .Size_64K) {
b.first_uncommitted_page = b.memory_base;
arena_set_alignment(*b, ARENA_DEFAULT_ALIGNMENT);
committed := arena_commit_first_pages(*b, reserve_size_bytes(*b) * 8);
expect(committed <= reserve_size_bytes(*b), "an oversized commit reported % against a % reserve",
committed, reserve_size_bytes(*b));
expect(committed == reserve_size_bytes(*b), "an oversized commit did not clamp to the full reserve; got %", committed);
arena_delete(*b);
}
}
// start_offset_bytes reserves extra room at the front. Currently unexercised by the
// module itself, so this is the only coverage it has.
{
a: Arena;
if arena_platform_init(*a, .Size_2M) {
a.first_uncommitted_page = a.memory_base;
arena_set_alignment(*a, ARENA_DEFAULT_ALIGNMENT);
committed := arena_commit_first_pages(*a, PLATFORM_MEMORY_PAGE_SIZE, PLATFORM_MEMORY_PAGE_SIZE);
expect(committed >= 2 * PLATFORM_MEMORY_PAGE_SIZE,
"one page plus a one page offset committed only % bytes", committed);
arena_delete(*a);
}
}
// Locking and unlocking pages. These can legitimately fail on a machine with a small
// working-set or RLIMIT_MEMLOCK, and the procedures log rather than assert, so the
// test checks that they leave the arena intact rather than that they succeed.
{
a := make_arena(.Size_2M);
defer arena_delete(*a);
p := arena_alloc(*a, 4096);
fill(p, 4096, 0x8E);
arena_lock_pages(*a);
expect(is_valid(*a), "the arena did not survive arena_lock_pages");
expect(all_bytes_are(p, 4096, 0x8E), "arena_lock_pages disturbed arena contents");
arena_unlock_pages(*a);
expect(is_valid(*a), "the arena did not survive arena_unlock_pages");
expect(all_bytes_are(p, 4096, 0x8E), "arena_unlock_pages disturbed arena contents");
q := arena_alloc(*a, 64);
expect(q != null, "the arena is unusable after a lock/unlock cycle");
}
// Locking an arena with nothing committed logs rather than misbehaving.
{
expecting_errors("locking an arena with no active pages");
a: Arena;
if arena_platform_init(*a, .Size_2M) {
a.first_uncommitted_page = a.memory_base;
arena_lock_pages(*a);
expect(is_valid(*a), "the arena did not survive locking with no pages");
arena_delete(*a);
}
}
// .Secure_Arena locks each range as it is committed, which is the property a one-shot lock
// does not have: a lock taken at init covers only the initial commit, and everything the
// arena grows into afterwards would be unlocked. Growing well past the initial commit here
// is what would catch a regression back to locking once.
//
// Whether the locks actually succeed depends on the machine's working-set quota or
// RLIMIT_MEMLOCK, so that half is reported rather than asserted -- same reasoning as the
// group above. What is asserted is that the arena stays usable and its contents survive,
// which is what would break if the lock/unlock bracketing around commit and decommit were
// wrong.
{
INITIAL_PAGES :: 2;
a := bootstrap_arena(.Size_2M, INITIAL_PAGES, flags = .Secure_Arena, label = "secure-growth");
expect(a != null, "bootstrap_arena with .Secure_Arena returned null");
if a {
defer arena_delete(a);
expect(arena_is_secure(a), "the .Secure_Arena flag did not survive bootstrap");
initial_committed := arena_committed_bytes(a);
// Well past the initial commit, so the growth path runs several times.
GROW_BYTES :: 32 * PLATFORM_MEMORY_PAGE_SIZE;
p := arena_alloc(a, GROW_BYTES);
expect(p != null, "a secure arena refused an allocation it had room for; a failed lock must not fail the commit");
if p {
fill(p, GROW_BYTES, 0x5C);
expect(arena_committed_bytes(a) > initial_committed,
"the arena did not grow past its initial commit, so this test proves nothing");
expect(all_bytes_are(p, GROW_BYTES, 0x5C), "growing a secure arena disturbed its contents");
}
// Release back down: the decommit path has to unlock first, and on Linux madvise would
// otherwise refuse the locked range and leave the pages committed.
arena_reset(a);
free_pages_down_to(a, INITIAL_PAGES);
expect(arena_committed_bytes(a) <= initial_committed,
"free_pages_down_to left % bytes committed on a secure arena, expected at most %",
arena_committed_bytes(a), initial_committed);
q := arena_alloc(a, 64);
expect(q != null, "a secure arena is unusable after a grow/release cycle");
emit(" secure arena lock intact after growth: % (quota permitting)\n",
arena_secure_lock_intact(a));
}
}
// Large pages. Whether the OS grants them depends on privilege (SeLockMemoryPrivilege on
// Windows) and, on Linux, on a hugetlbfs pool an administrator preallocated, so the grant
// itself is reported rather than asserted. What is asserted holds either way: the arena
// works, its page arithmetic agrees with whatever granularity it ended up with, and it
// survives a grow/release cycle on both the fully-committed and the decommittable path.
{
emit(" platform large page size: %\n", platform_large_page_size());
INITIAL_PAGES :: 2;
a := bootstrap_arena(.Size_64M, INITIAL_PAGES, flags = .Large_Pages, label = "large-pages");
expect(a != null, "bootstrap_arena with .Large_Pages returned null");
if a {
defer arena_delete(a);
expect(arena_is_large_pages(a), "the .Large_Pages flag did not survive bootstrap");
active := arena_large_pages_active(a);
emit(" large pages active: % (page size %)\n", active, arena_page_size(a));
if active {
expect(arena_page_size(a) == platform_large_page_size(),
"an active large-page arena reports a % byte page size, expected %",
arena_page_size(a), platform_large_page_size());
expect(arena_committed_bytes(a) % arena_page_size(a) == 0,
"committed bytes % is not a whole number of % byte pages",
arena_committed_bytes(a), arena_page_size(a));
} else {
expect(arena_page_size(a) == PLATFORM_MEMORY_PAGE_SIZE,
"an arena that fell back reports a % byte page size, expected %",
arena_page_size(a), PLATFORM_MEMORY_PAGE_SIZE);
}
initial_committed := arena_committed_bytes(a);
expect(initial_committed >= INITIAL_PAGES * arena_page_size(a),
"only % bytes committed, expected at least % pages of % bytes",
initial_committed, INITIAL_PAGES, arena_page_size(a));
// Sized off the arena's own page size, so this passes the initial commit whether the
// pages are 4 KB or 2 MB. A fixed byte count would not grow a large-page arena at all.
grow_bytes := 3 * arena_page_size(a) + 1234;
p := arena_alloc(a, grow_bytes);
expect(p != null, "a large-page arena refused an allocation it had room for");
if p {
fill(p, grow_bytes, 0xA7);
expect(all_bytes_are(p, grow_bytes, 0xA7), "growing a large-page arena disturbed its contents");
}
arena_reset(a);
free_pages_down_to(a, INITIAL_PAGES);
fully_committed := active && LARGE_PAGES_ARE_FULLY_COMMITTED;
if fully_committed {
// There is no partial decommit of a large-page allocation, so this arena rewinds but
// keeps its whole reservation. Asserting that pins the no-op down as deliberate.
expect(arena_committed_bytes(a) == reserve_size_bytes(a),
"a fully committed large-page arena reports % committed, expected the whole % byte reservation",
arena_committed_bytes(a), reserve_size_bytes(a));
} else {
expect(arena_committed_bytes(a) <= initial_committed,
"free_pages_down_to left % bytes committed, expected at most %",
arena_committed_bytes(a), initial_committed);
}
q := arena_alloc(a, 64);
expect(q != null, "a large-page arena is unusable after a grow/release cycle");
}
}
// .Large_Pages together with .Secure_Arena. Large pages are non-pageable already, so the
// arena locks nothing, and the pair of guarantees still has to come out intact -- including
// arena_unlock_pages, which must not fail against a range that was never locked.
{
// One initial page, because a 2 MB reserve holds exactly one 2 MB page: asking for two
// would trip the commit-size clamp and log about it.
a := bootstrap_arena(.Size_2M, 1, flags = .Large_Pages | .Secure_Arena, label = "large-secure");
expect(a != null, "bootstrap_arena with .Large_Pages | .Secure_Arena returned null");
if a {
defer arena_delete(a);
expect(arena_is_large_pages(a) && arena_is_secure(a), "both flags did not survive bootstrap");
p := arena_alloc(a, 4096);
expect(p != null, "a large-page secure arena refused an allocation it had room for");
if p {
fill(p, 4096, 0x3B);
expect(all_bytes_are(p, 4096, 0x3B), "a large-page secure arena disturbed its contents");
}
unlock_ok := arena_unlock_pages(a);
// Asserted only where it must hold: with large pages the arena locked nothing, so the
// unlock has nothing to fail at. On the 4 KB fallback path a machine whose quota refused
// the lock will refuse the unlock as well, which is the quota's answer, not a defect.
if arena_large_pages_active(a) {
expect(unlock_ok, "arena_unlock_pages failed on an arena backed by large pages, which locked nothing");
}
emit(" large+secure: large pages active %, lock intact %, unlock ok % (quota permitting)\n",
arena_large_pages_active(a), arena_secure_lock_intact(a), unlock_ok);
}
}
// A reservation that cannot hold one large page has to fall back rather than refuse. The
// warning reporting the fallback is the expected output, not a failure.
{
expecting_errors("a 64K reservation asking for large pages");
a := bootstrap_arena(.Size_64K, 2, flags = .Large_Pages, label = "large-pages-too-small");
expect(a != null, "bootstrap_arena refused instead of falling back to % byte pages", PLATFORM_MEMORY_PAGE_SIZE);
if a {
defer arena_delete(a);
expect(!arena_large_pages_active(a), "a 64K arena claims to be backed by large pages");
expect(arena_page_size(a) == PLATFORM_MEMORY_PAGE_SIZE,
"a 64K arena that fell back reports a % byte page size, expected %",
arena_page_size(a), PLATFORM_MEMORY_PAGE_SIZE);
p := arena_alloc(a, 64);
expect(p != null, "an arena that fell back to small pages is unusable");
}
}
}
// ---------------------------------------------------------------------------
// 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 single stress 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. Two did on the first run of this
// suite. Neither would have been caught by adding more iterations to a loop.
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 ARENA TESTS PASSED (% checks over % groups)\n", checks_run, group_count);
return true;
}
emit("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_arena_tests :: (builder: *String_Builder = null) -> bool {
output_builder = builder;
emit("=== arena.jai / arena_platform.jai test suite ===\n");
emit("size_of(Arena) = %, page size = %, default commit = % pages\n",
size_of(Arena), PLATFORM_MEMORY_PAGE_SIZE, ARENA_DEFAULT_COMMIT_PAGE_COUNT);
#if RUN_LIFECYCLE_TESTS { lifecycle_tests(); }
#if RUN_INTROSPECTION_TESTS { introspection_tests(); }
#if RUN_ALLOCATION_TESTS { allocation_tests(); }
#if RUN_BOUNDS_TESTS { bounds_tests(); }
#if RUN_ALIGNMENT_TESTS { alignment_tests(); }
#if RUN_RESET_TESTS { reset_tests(); }
#if RUN_SCOPED_TESTS { scoped_tests(); }
#if RUN_ALLOCATOR_TESTS { allocator_tests(); }
#if RUN_SECURE_TESTS { secure_tests(); }
#if RUN_PLATFORM_TESTS { platform_tests(); }
return report();
}