diff --git a/docs/api/pools.md b/docs/api/pools.md new file mode 100644 index 0000000..b0a3093 --- /dev/null +++ b/docs/api/pools.md @@ -0,0 +1,228 @@ +# Memory pools (`laige::ArenaPool`, `laige::Pool`) + +Budgeted, accounted, engine-owned memory pools (M0-CORE-05; PRD §10.4 +memory model, §9.1 S-2, AGENTS PERF-003, CPP-002/007). Public header: +`src/laige-core/include/laige/pools.h` (header-only). Unit suite: +`ctest -R pools` (`tests/laige-core/pools_tests.cpp`). + +The safe API has **no per-frame heap** (S-2): engine data lives in these +pools. A safe-API call that would allocate is either pooled internally or +refuses with `ErrorCode::BudgetExhausted` — a pool never grows silently +(G-R1). + +## The two kinds + +| | `ArenaPool` | `Pool` | +|---|---|---| +| Lifetime | Arena-scoped: all elements live until `reset()` (the per-frame release) | Element-scoped: created/destroyed individually, in any order | +| Addressing | Slot index (`std::uint32_t`), valid until the next `reset()` | Stable handle = index + generation (`PoolHandle`, CPP-007) | +| Recycle | None in-frame (bump cursor; `reset()` rewinds) | LIFO free list; freed slots recycle immediately | +| Release | `reset()` — O(inUse), idempotent | `destroy(handle)` — O(1); `clear()` — O(capacity) | +| Use for | Per-frame scratch (contacts, temp vectors, frame-local buffers) | Long-lived / cross-frame objects (entities, components, particles, audio sources, packets) | + +## Common contracts + +**Budget (S-6, PERF-008, SCALE-003).** The element capacity is fixed at +construction via `Options{capacity}` and is a *declared budget*: raising +it is a typed configuration change (API-006), never runtime behavior. A +`create()` beyond the budget returns `ErrorCode::BudgetExhausted` (no +growth, no crash, no silent degradation). A budget of 0 is legal: every +`create()` fails. + +**Allocation (PERF-002, PERF-003).** Construction performs the pool's +only backing allocation (a setup path, never a hot path). Every later +`create()`/`destroy()`/`reset()`/`clear()` is O(1) (LIFO free list for +`Pool`), allocates nothing, does no I/O, and takes no locks. The only +real work of a `create()` is the element's own constructor (placement +new). T must not throw from its constructor (NFR-8.10: the engine builds +with `-fno-exceptions`). + +**Accounting (PRD §10.4, FR-11.4, G-R4).** `stats()` returns a +`PoolStats` value — element counts (`capacity`, `inUse`, `peakInUse`), +the `totalCreated` churn counter, and backing-store byte counts. The +pool *publishes*; the M1 profiler (FR-11.4 memory inspector) *pulls* and +aggregates — there is no registration or callback (CORE-004: the sink +interface lands with the profiler, not before it). `peakInUse` is the +high-water mark since construction (FR-11.4 peak tracking); +`totalCreated` counts churn, not liveness (G-R4: a create immediately +destroyed still counts). + +**Errors (FR-12.1, CORE-008).** + +| Operation | Failure | Code | +|---|---|---| +| `create()` | budget exhausted | `ErrorCode::BudgetExhausted` (4) | +| `Pool::destroy()` | invalid or stale handle | `ErrorCode::InvalidArgument` (2) | + +Failures are `Result`/`Status` values — never exceptions, never silent. +The pool itself does not log: a failing `create()` stays a branch on the +cold path, and the owning system logs the failure under its own +subsystem name (LOG-002, G-R1 "pool overflow → logged degradation"). + +**Ownership (CPP-002, CONC-001).** A pool owns its elements and its +backing store. It is never copyable; it is movable (O(1) pointer swap) +and its destructor destroys every live element — no leak, ASan-verified +by the suite. A moved-from pool is a valid *empty* pool (capacity 0: +every `create()` fails, every handle invalid). Move assignment first +destroys the elements the destination currently owns, then takes over +the source's storage; the stats follow the storage (the moved-from pool +is zeroed). + +**Threading.** A pool has exactly **one owner thread** — it is NOT +thread-safe (CONC-001). Use it from one owner, typically one system's +update phase. Sharing across threads requires an explicit engine +synchronization boundary (CONC-002), which M1 defines per subsystem; +`stats()` is a plain value read that is safe once the owning phase has +ended. + +**Determinism (ARCH-010, PRD §10.3).** Pool allocation is pure integer +bookkeeping — no floating point, no platform intrinsics, no randomness. +The same create/destroy sequence produces bit-identical handle +sequences on every platform, so handles are safe to replicate and are +replay state from M1 on. (The generation-wrap case below is defined +unsigned wrap, hence bit-identical too.) + +## `laige::ArenaPool` + +```cpp +laige::ArenaPool pool(laige::ArenaPool::Options{128}); +auto s = pool.create(args...); // Result; slot, valid until reset() +Tracked& e = pool.at(s.value()); // asserts live (debug); undefined in release if stale +Tracked* p = pool.get(3); // nullptr if slot >= inUse +pool.reset(); // per-frame release: destroy all, rewind cursor +``` + +- `create(Args...)` — `Result`; the slot index, + or `BudgetExhausted`. O(1), no allocation. +- `reset()` — destroy every live element, rewind the cursor. O(inUse), + idempotent. `peakInUse`/`totalCreated` survive the reset + (since-construction counters; a per-frame profiler diffs them). +- `at(slot)` — `T&`; asserts the slot is live (debug: loud crash; + release: undefined — the engine Result convention). +- `get(slot)` — `T*`; null-safe read (nullptr when the slot is not + live). +- `isValid(slot)` — true while the slot is live. +- `capacity()`, `inUse()`, `stats()` — O(1), no allocation. + +**Slot lifetime:** a slot is valid until the next `reset()` — never +carry it across frames, and there is no per-element release in an arena +(`reset()` is the only release; that is the arena's contract). + +## `laige::Pool` + +```cpp +laige::Pool pool(laige::Pool::Options{4096}); +auto h = pool.create(args...); // Result +Entity& e = pool.at(h.value()); // asserts live (debug); undefined in release if stale +Entity* p = pool.get(h.value()); // nullptr when stale +Status r = pool.destroy(h.value()); // InvalidArgument when stale/invalid +pool.clear(); // destroy all; every handle becomes stale +``` + +- `create(Args...)` — `Result`; O(1), no + allocation. +- `destroy(handle)` — `Status`; O(1). Bumps the slot's generation (the + stale-handle check), returns the slot to the LIFO free list. +- `at(handle)` — `T&`; asserts the handle is live (debug: loud crash; + release: undefined). +- `get(handle)` — `T*`; null-safe read (nullptr when stale). +- `isValid(handle)` — generation-checked liveness (CPP-007). +- `clear()` — destroy every live element; every handle becomes stale; + the capacity is unchanged and the pool is immediately reusable. + O(capacity). (Reset-per-frame workloads should use `ArenaPool` — + its `reset()` is O(inUse) and has no free-list bookkeeping.) +- `capacity()`, `inUse()`, `stats()` — O(1), no allocation. + +### The handle contract (CPP-007) + +`PoolHandle{index, generation}` is a 64-bit pair: a 32-bit slot index +and a 32-bit generation. A handle is valid while the slot is live and +its current generation matches the handle's: + +- a slot starts at generation 1 (generation 0 is reserved — the default + `PoolHandle{}` is never valid); +- `destroy()`/`clear()` bump the slot's generation, so every stale + handle to that slot fails `isValid()` and can never pass again — + except after 2^32 frees of that one slot (defined unsigned wrap, + effectively unreachable; the one case the scheme does not rule out, + documented rather than assertable); +- a handle is meaningful only in the pool that produced it: + index+generation pairs from different pools are not comparable. + +**Stale-handle detection is the debug guarantee (S-9):** `at()` asserts +in debug builds (the suite proves the assert fires, in a forked child); +in every build, `isValid()` queries and `get()` reads null-safely. +Release behavior of `at()` on a stale handle is undefined, per the +engine Result convention (result.h). + +## `PoolStats` + +```cpp +struct PoolStats { + std::uint32_t capacity{}; // element budget (Options::capacity) + std::uint32_t inUse{}; // live elements right now + std::uint32_t peakInUse{}; // high-water mark of inUse since construction + std::uint64_t totalCreated{}; // successful create() calls since construction + std::size_t bytesCapacity{}; // backing store bytes (element slots + bookkeeping) + std::size_t bytesInUse{}; // element bytes occupied by live elements +}; +``` + +`bytesCapacity` is the whole backing store: for `ArenaPool` the +element block (`capacity * stride`); for `Pool` the element block plus +per-slot bookkeeping (4 B generation + 1 B alive flag + 4 B free-list +entry). `stride` is the aligned element size — +`sizeof(ElementSlot)` = the size of `T` rounded up to its alignment +(1 byte for an empty T). `bytesInUse` is the element footprint of the +live elements only. + +## Performance + +- **Complexity:** every hot-path operation is O(1) except + `Pool::clear()` (O(capacity)) and `ArenaPool::reset()` (O(inUse)) — + both are per-frame releases, budgeted by construction (PERF-008: the + work is bounded by the declared budget, never an arbitrary backlog). +- **Allocations:** none after construction (PERF-003). No std::function, + no virtual dispatch, no hash maps, no locks (PERF-006). +- **Data layout:** one contiguous aligned block per pool (PERF-004); + elements are placed densely; the LIFO free list keeps recycling + cache-local. +- **Common traps:** + - treating a `PoolHandle` as stable after `destroy()`/`clear()` + (use-after-free — check `isValid()`, or read via `get()`); + - carrying an `ArenaPool` slot across `reset()`; + - holding two pools' handles in the same container (cross-pool + comparison is meaningless); + - expecting a pool to grow when `BudgetExhausted` arrives (it will + never — raise the budget through configuration instead). + +## Performant example (per-frame contacts + cross-frame entities) + +```cpp +// Per-frame scratch: one arena, reset at the end of the frame. +laige::ArenaPool contacts(laige::ArenaPool::Options{512}); +for (auto const& pair : pairsThisFrame) { + auto r = contacts.create(pair.a, pair.b); + if (r.isError()) { /* log under your subsystem name (G-R1); drop the pair */ } + resolve(contacts.at(r.value())); +} +// ... at frame end: +contacts.reset(); + +// Cross-frame: stable handles, generation-checked. +laige::Pool entities(laige::Pool::Options{8192}); +auto h = entities.create(spawnArgs...); +if (h.isError()) { /* BudgetExhausted: log, refuse the spawn (S-2) */ } +// later, when the entity dies: +entities.destroy(h.value()); // h is stale from now on +``` + +## Misuse warnings + +- A stale `PoolHandle` through `at()` is a debug assert and release UB — + exactly the use-after-free the handle scheme exists to make + detectable (S-9). +- `ArenaPool` slots are frame-local; cross-frame data belongs in + `Pool`. +- A pool's `BudgetExhausted` is a *declared budget being exceeded* — + surface it (log + refuse), never "fix" it by letting the pool grow. diff --git a/docs/getting-started/building.md b/docs/getting-started/building.md index e6d5c70..e7c6aea 100644 --- a/docs/getting-started/building.md +++ b/docs/getting-started/building.md @@ -155,7 +155,10 @@ pinned set and the NaN/Inf policy): `sim_math.cpp`, `sim_math_fixed.cpp`; API contract, NaN/Inf policy, and the fpx16_16 rounding/saturation policy in [docs/api/sim_math.md](../api/sim_math.md), pinned flags via - `laige_apply_simmath_policy()`). + `laige_apply_simmath_policy()`), and the memory pools from M0-CORE-05 + (`include/laige/pools.h`: `laige::ArenaPool` and `laige::Pool` + with `laige::PoolStats` accounting; API contract in + [docs/api/pools.md](../api/pools.md)). - `tests/laige-core/laige-core_tests` is a CTest link smoke test (a GoogleTest suite since M0-DEP-01) that runs in every build tree above: it verifies the static/shared link and checks the NFR-8.10 policy flags with @@ -178,6 +181,13 @@ pinned set and the NaN/Inf policy): `FixedPointSaturation`, `FixedPointConversions`, `FixedPointSimMath`, `FixedPointDispatch`, and `FixedPointDeterminism` suites — the step's Verify command is `ctest -R math_fixed` (verified under ASan+UBSan). +- `pools` is the M0-CORE-05 CTest entry: a filtered view of the same + executable covering the `ArenaPoolBasics`, `ArenaPoolBudget`, + `PoolBasics`, `PoolBudget`, `PoolStale`, `PoolDestruction`, + `PoolStats`, and `PoolMove` suites — the step's Verify command is + `ctest -R pools` (budget exhaustion, reset semantics, and + generation-checked stale handles; the stale-handle assert runs in a + forked child on the POSIX jobs). - Every configure verifies the vendored dependency lock (`cmake/laige-deps-lock.cmake` against `deps.lock`); a tampered or unlisted file under `deps/` fails the configure loudly. GoogleTest is the diff --git a/roadmap/M0-foundations.md b/roadmap/M0-foundations.md index b5180ef..5a2defd 100644 --- a/roadmap/M0-foundations.md +++ b/roadmap/M0-foundations.md @@ -424,7 +424,7 @@ No rendering, no physics, no networking yet — `laige-core` only. saturation, conversions, and the two-implementation determinism property — cohesive, not split) -- [ ] **M0-CORE-05 · Pools: `ArenaPool` and `Pool`** +- [x] **M0-CORE-05 · Pools: `ArenaPool` and `Pool`** - **Refs:** PRD §9.1 (S-2), §10.4; AGENTS PERF-003, CPP-002/007 - **Depends:** M0-CORE-01 - **Scope:** @@ -432,8 +432,73 @@ No rendering, no physics, no networking yet — `laige-core` only. - `laige::Pool`: stable-handle pool (handle = index + generation, CPP-007), bounded, overflow → `Status::BudgetExhausted` (never grows silently). - Both report bytes/counts to a simple accounting sink (feeds the M1 profiler). - Unit tests: budget exhaustion returns error; reset semantics; generation-checked stale handle detection in debug. - - **Verify:** `ctest -R pools` green; exhaustion test asserts `Status` error, no crash, no leak (ASan). - - **Size:** ~300 lines + tests + - **Decision (2026-09-11):** header-only `include/laige/pools.h` + (no new .cpp: both pools are templates). `ArenaPool`: one + contiguous aligned block, bump cursor, `reset()` destroys the live + prefix and rewinds (O(inUse), idempotent); slots are `uint32_t`, + valid until the next `reset()`; no free list (in-frame recycling is + not an arena property). `Pool`: one element block + per-slot + generation table (starts at 1; 0 reserved — the default handle is + never valid) + LIFO free-list stack + alive flag; `destroy()` + bumps the generation (a stale handle can never pass again, except a + defined 2^32 unsigned wrap of one slot — documented as the one case + the scheme does not rule out) and recycles the slot (O(1)); + `handle = {index, generation}` (CPP-007); `clear()` is O(capacity) + (reset-per-frame workloads use the arena). Both: `Options{capacity}` + (uint32, the declared budget, S-6/API-006) fixed at construction — + the only backing allocation (setup path); every hot-path op O(1), + allocation-free, lock-free, noexcept (PERF-003/006); overflow → + `ErrorCode::BudgetExhausted` (4, already registered — no new code), + `destroy()` on an invalid/stale handle → `ErrorCode::InvalidArgument` + (2); the pool does not log — the owning system logs the failure + under its subsystem name (LOG-002, G-R1), keeping a failing create + a branch on the cold path. Move-only (O(1) pointer swap; a + moved-from pool is a valid empty pool; move assignment destroys the + destination's current elements first); the destructor destroys + every live element (no leak). Accounting: `stats()` returns + `laige::PoolStats` — capacity/inUse/peakInUse (FR-11.4 peak + tracking), totalCreated churn (G-R4), bytesCapacity (element slots + + per-slot bookkeeping) and bytesInUse — a plain value the M1 + profiler (FR-11.4) pulls; no registration/callback (CORE-004: the + sink interface lands with the profiler). Element storage is a plain + `ElementSlot` struct (`alignas(T) std::byte[...]`) — not + `std::aligned_storage`, which is deprecated since C++23 and whose + layout changed across implementations (GCC 16's C++26-era + libstdc++ makes the outer class an empty 1-byte wrapper — using it + directly would silently allocate 1-byte slots; caught by this + step's byte-accounting tests). Single-owner, not thread-safe + (CONC-001; M1 defines the cross-thread boundaries, CONC-002); + deterministic by construction (pure integer bookkeeping, ARCH-010). + API contract: `docs/api/pools.md`. + - **Verify:** `ctest -R pools` green — 25 GTest cases across + `ArenaPoolBasics` (slots, read-back, reset semantics, peak/churn + survival, zero budget, move), `ArenaPoolBudget`, `PoolBasics` + (LIFO recycle, slot stability), `PoolBudget`, `PoolStale` + (destroy/clear/reuse/default handles; generation bump `gen+1`; + double-destroy; the stale-handle `at()` assert proven in a forked + child dying on SIGABRT — POSIX jobs; Windows skips with a reason), + `PoolDestruction` (destructor/clear/reset destroy counts balanced — + leak-free), `PoolStats` (element/byte counts incl. the aligned- + stride case), `PoolMove`. The exhaustion tests assert the `Status` + error (`BudgetExhausted`), no crash, no growth, and reusability + after reset/destroy. `Tracked` ctor/dtor counters prove every + placement-new has a matching destroy (independently leak-free under + ASan). Verified locally 2026-09-11: full 11/11 ctest (incl. + `pools` and the real-tree include-lint) on GCC 16.2.1 (`build` + static, `build-shared` shared, `build-asan` ASan+UBSan fatal, + `build-tsan` TSan `halt_on_error=1`) and a Clang 22.1.8 tree + (`build-clang`), zero warnings under the NFR-8.10 policy. CI + observed 2026-09-11 via the GitHub API — PR #5 `ci-pull.yml` run + 34593510387 green (5/5 executed: linux-gcc, linux-clang, + linux-asan, linux-tsan, include-lint; macOS/Windows are merge-lane + jobs, skipped on PRs — the Windows job will additionally prove MSVC + compilation of this header at merge). + - **Size:** 458 lines header (`pools.h` — full AGENTS §9 contracts + next to the code) + 584 lines tests + 228 lines docs + ~7 lines + CMake (over the ~300-line estimate, same pattern as M0-CORE-01…04: + the header carries the API contract and the tests prove the step's + Verify clauses — exhaustion, reset, stale handles, accounting — + cohesive, not split) - [ ] **M0-CORE-06 · Deterministic PRNG** - **Refs:** PRD §10.3 (seeded, per-substream); AGENTS ARCH-010 diff --git a/src/laige-core/README.md b/src/laige-core/README.md index 32fabb7..231707e 100644 --- a/src/laige-core/README.md +++ b/src/laige-core/README.md @@ -45,6 +45,22 @@ Status: M0 — Foundations. also fixed a latent CTest filter bug (quoted `--gtest_filter` args silently under-ran the `result_status`/`logging` Verify suites — see `tests/laige-core/CMakeLists.txt`). +- **M0-CORE-04 (done):** the SimMath `fpx16_16` backend — the DEFAULT + deterministic math (ADR 0002, PRD §10.3): `laige::fpx16_16` (signed + Q16.16, saturating ops, round-to-nearest ties-to-even, defined + divide-by-zero, no NaN/Inf — no UB under any input, CPP-004) and the + `Fpx16_16` SimMath backend (`include/laige/fpx16_16.h`, + `sim_math_fixed.cpp`). API contract: `docs/api/sim_math.md`; unit + suite: `ctest -R math_fixed` (includes a two-implementation + determinism known-answer check). +- **M0-CORE-05 (done):** memory pools (PRD §10.4, §9.1 S-2): + `laige::ArenaPool` (arena-scoped, budgeted, reset-per-frame) and + `laige::Pool` (stable generation-checked handles, CPP-007), both + budgeted (overflow → `ErrorCode::BudgetExhausted`, never silent + growth), O(1) allocation-free hot paths, move-only, single-owner, + and reporting `laige::PoolStats` (bytes/counts, peak, churn) that the + M1 profiler aggregates (`include/laige/pools.h`, header-only). API + contract: `docs/api/pools.md`; unit suite: `ctest -R pools`. - Further public headers land with each M0-CORE-xx step. Canonical build commands: diff --git a/src/laige-core/include/laige/pools.h b/src/laige-core/include/laige/pools.h new file mode 100644 index 0000000..f44dd92 --- /dev/null +++ b/src/laige-core/include/laige/pools.h @@ -0,0 +1,458 @@ +// laige-core memory pools (M0-CORE-05). +// +// PRD §10.4 (memory model): engine-owned, budgeted, accounted pools are +// the home for per-frame and long-lived engine data. S-2 (PRD §9.1): the +// safe API has no per-frame heap — data goes through engine pools. This +// header ships the two pool kinds: +// +// ArenaPool Arena-scoped, bump-only storage with a reset-per-frame +// lifetime: one contiguous pre-allocated block; create() +// advances a cursor, reset() destroys every live element +// and rewinds it. Slots are valid until the next reset(). +// No per-element destruction, no in-frame recycling. +// +// Pool Stable-handle pool: a fixed set of slots addressed by +// generation-checked handles (CPP-007). Elements are +// created and destroyed in any order; freed slots are +// recycled LIFO, so an element's slot — and therefore its +// handle — is stable for the element's lifetime. +// +// --------------------------------------------------------------------------- +// Common contracts (both pools) +// --------------------------------------------------------------------------- +// +// Budget (PERF-003, PERF-008, SCALE-003): the element capacity is fixed +// at construction (Options::capacity) and is a *declared budget* (S-6). +// A create() beyond the budget returns ErrorCode::BudgetExhausted — a +// pool never grows silently (S-2, G-R1). Raising a budget is a typed +// configuration change (API-006), never a runtime behavior. +// +// Allocation (PERF-003, PERF-002): construction performs the pool's only +// backing allocation (a setup path, never a hot path). Every subsequent +// create()/destroy()/reset()/clear() is O(1) (Pool's free list is LIFO), +// allocates nothing, and does no I/O or synchronization. The only real +// work of a create is the element's own constructor (placement new). +// +// Accounting (PRD §10.4: "all memory is accounted in the profiler"; +// FR-11.4 peak tracking; G-R4 churn counting): stats() returns a +// PoolStats value — element counts (capacity, inUse, peakInUse), the +// totalCreated churn counter, and backing-store byte counts. The pool +// publishes; the M1 profiler (FR-11.4 memory inspector) pulls. There is +// no registration or callback (CORE-004: the smallest complete design; +// a sink interface would be speculative until the profiler lands). +// +// Errors (FR-12.1, CORE-008): failures are Result/Status values — never +// exceptions, never silent: +// +// create() beyond budget -> ErrorCode::BudgetExhausted +// destroy() invalid/stale handle -> ErrorCode::InvalidArgument +// +// The pool itself does not log: a failing create stays a branch on the +// cold path, and the owning system logs the failure under its own +// subsystem name (LOG-002, G-R1 "pool overflow -> logged degradation"). +// +// Ownership (CPP-002, CONC-001): a pool owns its elements and its +// backing store. It is copyable-never, movable (O(1) pointer swap), and +// its destructor/clear()/reset() destroys every live element — no leak, +// ASan-verifiable. A pool has exactly one owner thread: it is NOT +// thread-safe. Use it from one owner (typically one system's update +// phase); sharing across threads requires an explicit engine +// synchronization boundary (CONC-002), which M1 defines per subsystem. +// +// Determinism (ARCH-010, PRD §10.3): pool allocation is pure integer +// bookkeeping — no floating point, no platform intrinsics, no +// randomness, no ordering that depends on anything but the operation +// sequence. The same create/destroy sequence produces bit-identical +// handle sequences on every platform, so handles are safe to replicate +// and are replay state from M1 on. +// +// --------------------------------------------------------------------------- +// Misuse warnings +// --------------------------------------------------------------------------- +// - A PoolHandle held past destroy()/clear() is use-after-free. The +// engine's S-9 contract: fail loudly in debug — at() asserts — and +// check explicitly in every build: isValid() to query, get() for the +// null-safe read (nullptr when stale). +// - ArenaPool slots die with the next reset(): never carry a slot +// index across frames, and never destroy single arena elements +// (reset is the only release; that is the arena's contract). +// - A PoolHandle is meaningful only in the pool that produced it: +// index+generation pairs from different pools are not comparable. +// - T must not throw from its constructor (NFR-8.10: the engine builds +// with -fno-exceptions; a throwing T would terminate the process). + +#pragma once + +#include +#include +#include +#include +#include + +#include "laige/result.h" + +namespace laige { + +// --------------------------------------------------------------------------- +// Shared pool value types +// --------------------------------------------------------------------------- + +// One pool's accounting snapshot (PRD §10.4, FR-11.4, G-R4). A plain +// value the M1 profiler aggregates; there is no registration (CORE-004). +// +// capacity element budget (Options::capacity) +// inUse live elements right now +// peakInUse high-water mark of inUse since construction +// totalCreated successful create() calls since construction (churn) +// bytesCapacity backing store bytes (element slots + handle bookkeeping) +// bytesInUse element bytes occupied by live elements +struct PoolStats { + std::uint32_t capacity{}; + std::uint32_t inUse{}; + std::uint32_t peakInUse{}; + std::uint64_t totalCreated{}; + std::size_t bytesCapacity{}; + std::size_t bytesInUse{}; +}; + +// A stable handle into a Pool (CPP-007): a 32-bit slot index plus a +// 32-bit generation. A handle is valid while the slot is live and its +// current generation equals the handle's. A generation bump on free +// invalidates every stale handle to that slot; a generation wrap after +// 2^32 frees of one slot is defined unsigned wraparound and is +// effectively unreachable (documented, not assertable — see +// Pool::isValid). A default handle {0, 0} is never valid. +// +// A handle is only meaningful in the pool that produced it (see the +// header misuse warnings). Comparison operators compare the pair. +struct PoolHandle { + std::uint32_t index{}; + std::uint32_t generation{}; +}; + +inline bool operator==(PoolHandle a, PoolHandle b) noexcept { + return a.index == b.index && a.generation == b.generation; +} +inline bool operator!=(PoolHandle a, PoolHandle b) noexcept { + return !(a == b); +} + +// Raw aligned storage for one element: a byte array with exactly the +// size of T (1 byte for an empty T) and its alignment, so placement +// new below is always well-formed (CPP-004). A plain struct — not +// std::aligned_storage, which is deprecated since C++23 and whose +// layout has changed across implementations — so ElementSlot is +// well-formed on every P0 toolchain. +template +struct ElementSlot { + alignas(T) std::byte data[(sizeof(T) == 0) ? 1 : sizeof(T)]; +}; + +// --------------------------------------------------------------------------- +// ArenaPool — arena-scoped, budgeted, reset-per-frame storage +// --------------------------------------------------------------------------- + +template +class ArenaPool { + public: + // The element budget, fixed at construction (S-6). A budget of 0 is + // legal: every create() fails with BudgetExhausted. + struct Options { + std::uint32_t capacity{}; + }; + + // One backing allocation for the whole arena (setup path, PERF-002). + explicit ArenaPool(Options options) noexcept + : capacity_(options.capacity), + block_(capacity_ > 0 + ? std::make_unique[]>(capacity_) + : nullptr), + inUse_(0), peakInUse_(0), totalCreated_(0) {} + + ArenaPool(const ArenaPool&) = delete; + ArenaPool& operator=(const ArenaPool&) = delete; + + // Destroys every live element (no leak; ASan-verifiable). O(inUse). + ~ArenaPool() noexcept { reset(); } + + // Move is an O(1) pointer swap; the source becomes an empty arena + // (capacity 0, every create() fails). + ArenaPool(ArenaPool&& other) noexcept + : capacity_(other.capacity_), block_(std::move(other.block_)), + inUse_(other.inUse_), peakInUse_(other.peakInUse_), + totalCreated_(other.totalCreated_) { + other.capacity_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; + } + // Move assignment: destroy the elements this currently owns, take + // over other's storage, and leave other a valid empty pool — the same + // net effect as a move construction. O(1). + ArenaPool& operator=(ArenaPool&& other) noexcept { + if (this == &other) return *this; + reset(); // destroy what this currently owns + capacity_ = other.capacity_; + block_ = std::move(other.block_); + inUse_ = other.inUse_; + peakInUse_ = other.peakInUse_; + totalCreated_ = other.totalCreated_; + other.capacity_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; + return *this; + } + + // Create one element in the next free slot (bump). O(1), no + // allocation; the element's constructor runs here (the op's only real + // work). Returns the slot index (valid until the next reset()). + // Beyond the budget: ErrorCode::BudgetExhausted (the arena never + // grows silently, S-2). + template + [[nodiscard]] Result create(Args&&... args) { + if (inUse_ == capacity_) return ErrorCode::BudgetExhausted; + ::new (static_cast(&block_[inUse_])) T( + std::forward(args)...); + const std::uint32_t slot = inUse_; + ++inUse_; + if (inUse_ > peakInUse_) peakInUse_ = inUse_; + ++totalCreated_; + return slot; + } + + // The per-frame release: destroy every live element and rewind the + // cursor. O(inUse), no allocation. peakInUse/totalCreated survive the + // reset (they are since-construction counters; a per-frame profiler + // diffs them). Idempotent. + void reset() noexcept { + for (std::uint32_t i = 0; i < inUse_; ++i) { + static_cast(static_cast(&block_[i]))->~T(); + } + inUse_ = 0; + } + + // Element access. at() asserts the slot is live (debug: a loud crash + // with a message; release: undefined behavior — the engine Result + // convention). get() is the null-safe read: nullptr for a slot >= + // inUse (after reset, or never created). + [[nodiscard]] T& at(std::uint32_t slot) { + assert(slot < inUse_ && + "ArenaPool::at: slot is not live (reset or never created)"); + return *static_cast(static_cast(&block_[slot])); + } + [[nodiscard]] T* get(std::uint32_t slot) noexcept { + return slot < inUse_ + ? static_cast(static_cast(&block_[slot])) + : nullptr; + } + + // True while the slot is live (i.e. until the next reset()). + [[nodiscard]] bool isValid(std::uint32_t slot) const noexcept { + return slot < inUse_; + } + + [[nodiscard]] std::uint32_t capacity() const noexcept { return capacity_; } + [[nodiscard]] std::uint32_t inUse() const noexcept { return inUse_; } + + // Accounting snapshot (PRD §10.4). bytesCapacity is the whole backing + // block; bytesInUse is the element footprint of the live prefix. + [[nodiscard]] PoolStats stats() const noexcept { + const std::size_t stride = sizeof(ElementSlot); + return PoolStats{ + capacity_, inUse_, peakInUse_, totalCreated_, + static_cast(capacity_) * stride, + static_cast(inUse_) * stride}; + } + + private: + std::uint32_t capacity_; + std::unique_ptr[]> block_; + std::uint32_t inUse_; + std::uint32_t peakInUse_; + std::uint64_t totalCreated_; +}; + +// --------------------------------------------------------------------------- +// Pool — stable-handle, generation-checked, bounded pool +// --------------------------------------------------------------------------- + +template +class Pool { + public: + // The element budget, fixed at construction (S-6). A budget of 0 is + // legal: every create() fails with BudgetExhausted. + struct Options { + std::uint32_t capacity{}; + }; + + // Backing allocations for the whole pool (setup path, PERF-002): the + // element store, the per-slot generation table, the LIFO free-list + // stack (pre-filled 0..capacity-1), and the per-slot alive flag. + explicit Pool(Options options) noexcept + : capacity_(options.capacity), + block_(capacity_ > 0 ? std::make_unique[]>(capacity_) + : nullptr), + generations_(capacity_ > 0 ? std::make_unique(capacity_) + : nullptr), + freeStack_(capacity_ > 0 ? std::make_unique(capacity_) + : nullptr), + alive_(capacity_ > 0 ? std::make_unique(capacity_) + : nullptr), + freeCount_(0), inUse_(0), peakInUse_(0), totalCreated_(0) { + for (std::uint32_t i = 0; i < capacity_; ++i) { + generations_[i] = 1; // generation 0 is reserved: never a handle gen + freeStack_[i] = i; + ++freeCount_; + } + } + + Pool(const Pool&) = delete; + Pool& operator=(const Pool&) = delete; + + // Destroys every live element (no leak; ASan-verifiable). O(capacity). + ~Pool() noexcept { clear(); } + + // Move is an O(1) pointer swap; the source becomes an empty pool + // (capacity 0: every create() fails, every handle invalid). + Pool(Pool&& other) noexcept + : capacity_(other.capacity_), block_(std::move(other.block_)), + generations_(std::move(other.generations_)), + freeStack_(std::move(other.freeStack_)), + alive_(std::move(other.alive_)), freeCount_(other.freeCount_), + inUse_(other.inUse_), peakInUse_(other.peakInUse_), + totalCreated_(other.totalCreated_) { + other.capacity_ = 0; + other.freeCount_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; + } + // Move assignment: destroy the elements this currently owns, take + // over other's storage, and leave other a valid empty pool — the same + // net effect as a move construction. O(1). + Pool& operator=(Pool&& other) noexcept { + if (this == &other) return *this; + clear(); // destroy what this currently owns + capacity_ = other.capacity_; + block_ = std::move(other.block_); + generations_ = std::move(other.generations_); + freeStack_ = std::move(other.freeStack_); + alive_ = std::move(other.alive_); + freeCount_ = other.freeCount_; + inUse_ = other.inUse_; + peakInUse_ = other.peakInUse_; + totalCreated_ = other.totalCreated_; + other.capacity_ = 0; + other.freeCount_ = 0; + other.inUse_ = 0; + other.peakInUse_ = 0; + other.totalCreated_ = 0; + return *this; + } + + // Create one element in the next free slot (LIFO recycle). O(1), no + // allocation; the element's constructor runs here. Beyond the budget: + // ErrorCode::BudgetExhausted (the pool never grows silently, S-2). + template + [[nodiscard]] Result create(Args&&... args) { + if (freeCount_ == 0) return ErrorCode::BudgetExhausted; + const std::uint32_t slot = freeStack_[--freeCount_]; + ::new (static_cast(&block_[slot])) T( + std::forward(args)...); + alive_[slot] = 1; + ++inUse_; + if (inUse_ > peakInUse_) peakInUse_ = inUse_; + ++totalCreated_; + return PoolHandle{slot, generations_[slot]}; + } + + // Destroy one live element and return its slot to the free list. + // O(1), no allocation. The slot's generation is bumped, so every + // stale handle to it now fails isValid() (CPP-007). Invalid or stale + // handle: ErrorCode::InvalidArgument (a caller bug — S-9). + [[nodiscard]] Status destroy(PoolHandle handle) noexcept { + if (!isValid(handle)) return ErrorCode::InvalidArgument; + static_cast(static_cast(&block_[handle.index]))->~T(); + alive_[handle.index] = 0; + ++generations_[handle.index]; + freeStack_[freeCount_++] = handle.index; + --inUse_; + return Status{}; + } + + // Element access. at() asserts the handle is live (debug: a loud crash + // with a message; release: undefined behavior — the engine Result + // convention). get() is the null-safe read: nullptr when the handle is + // stale, cleared, or from another pool. + [[nodiscard]] T& at(PoolHandle handle) { + assert(isValid(handle) && + "Pool::at: handle is stale or invalid (destroyed, cleared, or " + "from another pool)"); + return *static_cast(static_cast(&block_[handle.index])); + } + [[nodiscard]] T* get(PoolHandle handle) noexcept { + return isValid(handle) + ? static_cast(static_cast(&block_[handle.index])) + : nullptr; + } + + // Generation-checked liveness (CPP-007): the slot exists, is live, and + // its current generation matches the handle's. A handle for a freed + // slot mismatches on the generation bump and can never pass again — + // except after 2^32 frees of that one slot (defined unsigned wrap; + // effectively unreachable, documented here as the one case the + // generation scheme does not rule out). + [[nodiscard]] bool isValid(PoolHandle handle) const noexcept { + return handle.index < capacity_ && handle.generation != 0 && + alive_[handle.index] != 0 && + generations_[handle.index] == handle.generation; + } + + // Destroy every live element; every handle becomes stale; the capacity + // is unchanged and the pool is immediately reusable. O(capacity), no + // allocation. (Reset-per-frame workloads should use ArenaPool + // instead — its reset() is O(inUse) and has no free-list bookkeeping.) + void clear() noexcept { + for (std::uint32_t i = 0; i < capacity_; ++i) { + if (alive_[i] != 0) { + static_cast(static_cast(&block_[i]))->~T(); + alive_[i] = 0; + ++generations_[i]; + freeStack_[freeCount_++] = i; + } + } + inUse_ = 0; + } + + [[nodiscard]] std::uint32_t capacity() const noexcept { return capacity_; } + [[nodiscard]] std::uint32_t inUse() const noexcept { return inUse_; } + + // Accounting snapshot (PRD §10.4). bytesCapacity is the whole backing + // store (element slots + per-slot generation/alive/free-list + // bookkeeping); bytesInUse is the element footprint of live elements. + [[nodiscard]] PoolStats stats() const noexcept { + const std::size_t stride = sizeof(ElementSlot); + // Per-slot bookkeeping: generation (4 B) + alive flag (1 B) + + // free-list stack entry (4 B). + const std::size_t bookkeepingPerSlot = 9; + return PoolStats{ + capacity_, inUse_, peakInUse_, totalCreated_, + static_cast(capacity_) * + (stride + bookkeepingPerSlot), + static_cast(inUse_) * stride}; + } + + private: + std::uint32_t capacity_; + std::unique_ptr[]> block_; + std::unique_ptr generations_; + std::unique_ptr freeStack_; + std::unique_ptr alive_; + std::uint32_t freeCount_; + std::uint32_t inUse_; + std::uint32_t peakInUse_; + std::uint64_t totalCreated_; +}; + +} // namespace laige diff --git a/tests/laige-core/CMakeLists.txt b/tests/laige-core/CMakeLists.txt index 805cbbe..7124476 100644 --- a/tests/laige-core/CMakeLists.txt +++ b/tests/laige-core/CMakeLists.txt @@ -12,7 +12,7 @@ # M0-CORE-xx steps follow the same pattern). set(LAIGE_CORE_TEST_SOURCES laige-core_tests.cpp result_status_tests.cpp logging_tests.cpp math_float_tests.cpp - math_fixed_tests.cpp) + math_fixed_tests.cpp pools_tests.cpp) # M0-CORE-02: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own new/delete # (strong symbols in the Clang/GCC TSan runtime archives, interposed by @@ -91,10 +91,17 @@ add_test(NAME math_fixed COMMAND laige-core_tests --gtest_filter=FixedPointBasics.*:FixedPointRounding.*:FixedPointSaturation.*:FixedPointConversions.*:FixedPointSimMath.*:FixedPointDispatch.*:FixedPointDeterminism.*) +# M0-CORE-05: memory pools (ArenaPool, Pool). The step's Verify +# command is `ctest -R pools`; this entry selects exactly the pool +# suites from the shared laige-core_tests executable. +add_test(NAME pools + COMMAND laige-core_tests + --gtest_filter=ArenaPoolBasics.*:ArenaPoolBudget.*:PoolBasics.*:PoolBudget.*:PoolStale.*:PoolDestruction.*:PoolStats.*:PoolMove.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. set_tests_properties( - laige-core_tests result_status logging math_float math_fixed + laige-core_tests result_status logging math_float math_fixed pools PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-core/pools_tests.cpp b/tests/laige-core/pools_tests.cpp new file mode 100644 index 0000000..6389ca7 --- /dev/null +++ b/tests/laige-core/pools_tests.cpp @@ -0,0 +1,584 @@ +// laige-core memory pools suite (M0-CORE-05). +// +// Step Verify scope: +// - budget exhaustion returns a Status error (ErrorCode::BudgetExhausted), +// no crash, no leak (ASan runs this suite leak-free; the Tracked +// counters prove every placement-new has a matching destroy) +// - reset semantics: ArenaPool::reset() destroys all live elements, +// rewinds the cursor, and is idempotent +// - generation-checked stale handle detection (CPP-007): stale, +// cleared, reused-slot, and default handles all fail isValid(); +// the debug assert in Pool::at() aborts a forked child on a stale +// handle (S-9: fail loudly in debug) +// - accounting: PoolStats element/byte counts, peak, and churn +// (totalCreated) track the documented semantics +// +// Runs as CTest `pools` (the step's Verify command is `ctest -R pools`): +// a filtered view of the shared laige-core_tests executable, selecting +// exactly the suites below. + +#include +#include + +#include "gtest/gtest.h" +#include "laige/errors.h" +#include "laige/pools.h" + +#if defined(__unix__) +#include +#include +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "pools_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "pools_tests must be built with exceptions disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "pools_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define POOLS_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define POOLS_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if POOLS_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "pools_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +namespace { + +// A T whose constructor/destructor calls are observable: proves create() +// placement-news and destroy()/reset()/clear()/destruction run exactly +// once per element (leak-free; ASan verifies independently). +class Tracked { + public: + explicit Tracked(int value) : value_(value) { + ++constructed_; + ++live_; + } + ~Tracked() { + ++destroyed_; + --live_; + } + + int value() const { return value_; } + + static int constructed() { return constructed_; } + static int destroyed() { return destroyed_; } + static int live() { return live_; } + static void resetCounters() { + constructed_ = 0; + destroyed_ = 0; + live_ = 0; + } + + private: + int value_{}; + static inline int constructed_{0}; + static inline int destroyed_{0}; + static inline int live_{0}; +}; + +// An element with non-trivial alignment: pins the ElementSlot stride +// (max(sizeof(T), alignof(T))) in the bytes accounting. +struct alignas(16) Padded { + float a{}; + char b{}; +}; + +} // namespace + +// --------------------------------------------------------------------------- +// ArenaPool +// --------------------------------------------------------------------------- + +TEST(ArenaPoolBasics, CreateAssignsAscendingSlotsAndReadsBack) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{4}); + const auto s0 = pool.create(10); + const auto s1 = pool.create(20); + const auto s2 = pool.create(30); + ASSERT_TRUE(s0.ok() && s1.ok() && s2.ok()); + EXPECT_EQ(s0.value(), 0u); + EXPECT_EQ(s1.value(), 1u); + EXPECT_EQ(s2.value(), 2u); + EXPECT_EQ(pool.at(s0.value()).value(), 10); + EXPECT_EQ(pool.at(s1.value()).value(), 20); + EXPECT_EQ(pool.at(s2.value()).value(), 30); + EXPECT_EQ(pool.inUse(), 3u); + EXPECT_EQ(pool.capacity(), 4u); + EXPECT_TRUE(pool.isValid(2)); + EXPECT_FALSE(pool.isValid(3)); + EXPECT_EQ(pool.get(2), &pool.at(2)); + EXPECT_EQ(pool.get(3), nullptr); +} + +TEST(ArenaPoolBasics, ResetDestroysAllAndRewindsCursor) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{3}); + ASSERT_TRUE(pool.create(10).ok()); + ASSERT_TRUE(pool.create(20).ok()); + ASSERT_TRUE(pool.create(30).ok()); + EXPECT_EQ(Tracked::live(), 3); + + pool.reset(); + EXPECT_EQ(Tracked::live(), 0); + EXPECT_EQ(Tracked::destroyed(), 3); + EXPECT_EQ(pool.inUse(), 0); + EXPECT_FALSE(pool.isValid(0)); + EXPECT_EQ(pool.get(0), nullptr); + + // Idempotent: a second reset is a no-op. + pool.reset(); + EXPECT_EQ(Tracked::destroyed(), 3); + + // The cursor rewound: the next create reuses slot 0. + const auto s = pool.create(40); + ASSERT_TRUE(s.ok()); + EXPECT_EQ(s.value(), 0u); + EXPECT_EQ(pool.at(s.value()).value(), 40); +} + +TEST(ArenaPoolBasics, PeakAndChurnSurviveReset) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{4}); + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + ASSERT_TRUE(pool.create(3).ok()); + pool.reset(); + ASSERT_TRUE(pool.create(4).ok()); + ASSERT_TRUE(pool.create(5).ok()); + const laige::PoolStats s = pool.stats(); + EXPECT_EQ(s.peakInUse, 3u); // high-water mark since construction + EXPECT_EQ(s.totalCreated, 5u); // churn across both frames + EXPECT_EQ(s.inUse, 2u); +} + +TEST(ArenaPoolBasics, ZeroBudgetPoolRejectsEverything) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{0}); + const auto r = pool.create(1); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); + const laige::PoolStats s = pool.stats(); + EXPECT_EQ(s.capacity, 0u); + EXPECT_EQ(s.bytesCapacity, 0u); +} + +TEST(ArenaPoolBasics, MoveTransfersOwnership) { + Tracked::resetCounters(); + laige::ArenaPool a(laige::ArenaPool::Options{3}); + ASSERT_TRUE(a.create(7).ok()); + ASSERT_TRUE(a.create(8).ok()); + + laige::ArenaPool b(std::move(a)); + EXPECT_EQ(b.inUse(), 2u); + EXPECT_EQ(b.capacity(), 3u); + EXPECT_EQ(b.at(1).value(), 8); + EXPECT_EQ(a.inUse(), 0u); + EXPECT_EQ(a.capacity(), 0u); + EXPECT_FALSE(a.create(9).ok()); // moved-from: empty arena + EXPECT_EQ(Tracked::live(), 2); // the move moved nothing but pointers +} + +TEST(ArenaPoolBasics, MoveAssignmentTransfersOwnership) { + Tracked::resetCounters(); + laige::ArenaPool a(laige::ArenaPool::Options{2}); + ASSERT_TRUE(a.create(7).ok()); + laige::ArenaPool b(laige::ArenaPool::Options{1}); + b = std::move(a); + EXPECT_EQ(b.inUse(), 1u); + EXPECT_EQ(b.capacity(), 2u); + EXPECT_EQ(b.at(0).value(), 7); + EXPECT_EQ(a.inUse(), 0u); + EXPECT_EQ(a.capacity(), 0u); +} + +// --------------------------------------------------------------------------- +// ArenaPool budget (the step's Verify clause: exhaustion -> Status error, +// no crash, no leak) +// --------------------------------------------------------------------------- + +TEST(ArenaPoolBudget, ExhaustionReturnsBudgetExhaustedAndNeverGrows) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{2}); + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + + // The 3rd create hits the budget: an error value, no crash, no growth. + const auto r = pool.create(3); + EXPECT_FALSE(r.ok()); + EXPECT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(pool.inUse(), 2u); + EXPECT_EQ(pool.stats().capacity, 2u); + EXPECT_EQ(Tracked::live(), 2); + + // Repeated exhaustion stays a clean error (no partial state). + EXPECT_EQ(pool.create(4).error(), laige::ErrorCode::BudgetExhausted); + + // The budget is reusable after reset (per-frame contract). + pool.reset(); + EXPECT_TRUE(pool.create(5).ok()); + EXPECT_EQ(pool.at(0).value(), 5); + EXPECT_EQ(Tracked::live(), 1); +} + +// --------------------------------------------------------------------------- +// Pool — handles, recycling, stability +// --------------------------------------------------------------------------- + +TEST(PoolBasics, CreateReadsBackAndDestroyRecyclesLifo) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{3}); + const auto a = pool.create(1); + const auto b = pool.create(2); + ASSERT_TRUE(a.ok() && b.ok()); + EXPECT_EQ(pool.at(a.value()).value(), 1); + EXPECT_EQ(pool.at(b.value()).value(), 2); + EXPECT_EQ(pool.inUse(), 2u); + + // Freeing a's slot and creating again recycles it (LIFO free list); + // b is undisturbed — slot stability while live (CPP-007). + ASSERT_TRUE(pool.destroy(a.value()).ok()); + EXPECT_EQ(pool.inUse(), 1u); + const auto c = pool.create(3); + ASSERT_TRUE(c.ok()); + EXPECT_EQ(c.value().index, a.value().index); + EXPECT_EQ(pool.at(b.value()).value(), 2); + EXPECT_EQ(pool.at(c.value()).value(), 3); +} + +TEST(PoolBasics, HandlesAreStableWhileLive) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{4}); + const auto a = pool.create(1); + const auto b = pool.create(2); + const auto d = pool.create(3); + ASSERT_TRUE(a.ok() && b.ok() && d.ok()); + ASSERT_TRUE(pool.destroy(b.value()).ok()); + ASSERT_TRUE(pool.destroy(d.value()).ok()); + // a survives the destructions of every other element. + EXPECT_TRUE(pool.isValid(a.value())); + EXPECT_EQ(pool.at(a.value()).value(), 1); +} + +TEST(PoolBasics, ZeroBudgetPoolRejectsEverything) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{0}); + const auto r = pool.create(1); + EXPECT_FALSE(r.ok()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); + const laige::PoolStats s = pool.stats(); + EXPECT_EQ(s.capacity, 0u); + EXPECT_EQ(s.bytesCapacity, 0u); +} + +// --------------------------------------------------------------------------- +// Pool budget (the step's Verify clause: exhaustion -> Status error, no +// crash, no leak, no silent growth) +// --------------------------------------------------------------------------- + +TEST(PoolBudget, ExhaustionReturnsBudgetExhaustedAndNeverGrows) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{2}); + const auto a = pool.create(1); + const auto b = pool.create(2); + ASSERT_TRUE(a.ok() && b.ok()); + + const auto r = pool.create(3); + EXPECT_FALSE(r.ok()); + EXPECT_TRUE(r.isError()); + EXPECT_EQ(r.error(), laige::ErrorCode::BudgetExhausted); + EXPECT_EQ(pool.inUse(), 2u); + EXPECT_EQ(pool.stats().capacity, 2u); // never grew + EXPECT_EQ(Tracked::live(), 2); + + // Repeated exhaustion stays a clean error. + EXPECT_EQ(pool.create(4).error(), laige::ErrorCode::BudgetExhausted); + + // Destroying one element frees budget for the next create. + ASSERT_TRUE(pool.destroy(a.value()).ok()); + const auto c = pool.create(3); + ASSERT_TRUE(c.ok()); + EXPECT_EQ(pool.at(c.value()).value(), 3); + EXPECT_EQ(pool.inUse(), 2u); + EXPECT_EQ(Tracked::live(), 2); +} + +// --------------------------------------------------------------------------- +// Pool stale-handle detection (CPP-007) +// --------------------------------------------------------------------------- + +TEST(PoolStale, StaleAfterDestroyFailsIsValidAndGet) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{2}); + const auto h = pool.create(1); + ASSERT_TRUE(h.ok()); + ASSERT_TRUE(pool.destroy(h.value()).ok()); + EXPECT_FALSE(pool.isValid(h.value())); + EXPECT_EQ(pool.get(h.value()), nullptr); +} + +TEST(PoolStale, StaleAfterClear) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{2}); + const auto a = pool.create(1); + const auto b = pool.create(2); + ASSERT_TRUE(a.ok() && b.ok()); + pool.clear(); + EXPECT_FALSE(pool.isValid(a.value())); + EXPECT_FALSE(pool.isValid(b.value())); + EXPECT_EQ(pool.inUse(), 0u); + + // The pool is reusable: fresh handles are valid, old ones are not. + const auto c = pool.create(3); + ASSERT_TRUE(c.ok()); + EXPECT_TRUE(pool.isValid(c.value())); + EXPECT_EQ(pool.at(c.value()).value(), 3); + EXPECT_FALSE(pool.isValid(a.value())); + EXPECT_FALSE(pool.isValid(b.value())); +} + +TEST(PoolStale, ReusedSlotBumpsGeneration) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{1}); + const auto h1 = pool.create(1); + ASSERT_TRUE(h1.ok()); + ASSERT_TRUE(pool.destroy(h1.value()).ok()); + const auto h2 = pool.create(2); + ASSERT_TRUE(h2.ok()); + + // Same slot (the only one), next generation: the old handle is stale, + // the new one is live. This is the generation check the roadmap + // names — a stale handle can never pass again (short of a 2^32 + // wrap of one slot, documented in the header). + EXPECT_EQ(h2.value().index, h1.value().index); + EXPECT_EQ(h2.value().generation, h1.value().generation + 1); + EXPECT_FALSE(pool.isValid(h1.value())); + EXPECT_TRUE(pool.isValid(h2.value())); + EXPECT_EQ(pool.at(h2.value()).value(), 2); +} + +TEST(PoolStale, DefaultHandleIsNeverValid) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{1}); + EXPECT_FALSE(pool.isValid(laige::PoolHandle{})); + EXPECT_EQ(pool.get(laige::PoolHandle{}), nullptr); +} + +TEST(PoolStale, DestroyInvalidHandleFailsWithInvalidArgument) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{1}); + const auto s = pool.destroy(laige::PoolHandle{}); + EXPECT_FALSE(s.ok()); + EXPECT_EQ(s.error(), laige::ErrorCode::InvalidArgument); + + // Double destroy of a live element fails cleanly (no double free). + const auto h = pool.create(1); + ASSERT_TRUE(h.ok()); + ASSERT_TRUE(pool.destroy(h.value()).ok()); + const auto s2 = pool.destroy(h.value()); + EXPECT_FALSE(s2.ok()); + EXPECT_EQ(s2.error(), laige::ErrorCode::InvalidArgument); + EXPECT_EQ(Tracked::destroyed(), 1); + EXPECT_EQ(Tracked::live(), 0); +} + +TEST(PoolStale, AtStaleHandleAbortsInDebug) { + Tracked::resetCounters(); + // S-9: a stale handle through at() must fail loudly in debug (the + // generation check asserts). Exercised in a forked child so the test + // process survives: the child must die on SIGABRT. POSIX only (fork); + // the Windows jobs skip with a reason. +#if defined(__unix__) +# if defined(NDEBUG) + GTEST_SKIP() << "assert-based stale detection is a debug-build property"; +# else + const pid_t pid = fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + laige::Pool pool(laige::Pool::Options{1}); + const auto r = pool.create(7); + if (!r.ok() || !pool.destroy(r.value()).ok()) _exit(117); + const Tracked& probe = pool.at(r.value()); // stale: the debug assert + static_cast(probe); // must fire before this + _exit(1); // unreachable: no assert would make the parent fail below + } + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGABRT) + << "expected the stale-handle assert to abort the child (SIGABRT)"; +# endif +#else + GTEST_SKIP() << "fork() is not available on Windows; the stale-handle " + "assert is exercised on the POSIX jobs."; +#endif +} + +// --------------------------------------------------------------------------- +// Destruction accounting (no leaks: every placement-new is destroyed) +// --------------------------------------------------------------------------- + +TEST(PoolDestruction, DestructorDestroysRemainingLiveElements) { + Tracked::resetCounters(); + { + laige::Pool pool(laige::Pool::Options{4}); + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + ASSERT_TRUE(pool.create(3).ok()); + ASSERT_TRUE(pool.destroy(pool.create(4).value()).ok()); + EXPECT_EQ(Tracked::live(), 3); + } // pool destroyed: the 3 live elements must go with it + EXPECT_EQ(Tracked::constructed(), 4); + EXPECT_EQ(Tracked::destroyed(), 4); + EXPECT_EQ(Tracked::live(), 0); +} + +TEST(PoolDestruction, ClearDestroysEveryLiveElement) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{4}); + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + ASSERT_TRUE(pool.create(3).ok()); + pool.clear(); + EXPECT_EQ(Tracked::destroyed(), 3); + EXPECT_EQ(Tracked::live(), 0); +} + +TEST(PoolDestruction, ArenaDestructorDestroysRemainingLiveElements) { + Tracked::resetCounters(); + { + laige::ArenaPool pool(laige::ArenaPool::Options{4}); + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + } + EXPECT_EQ(Tracked::constructed(), 2); + EXPECT_EQ(Tracked::destroyed(), 2); + EXPECT_EQ(Tracked::live(), 0); +} + +// --------------------------------------------------------------------------- +// Accounting (PoolStats; PRD §10.4, FR-11.4, G-R4) +// --------------------------------------------------------------------------- + +TEST(PoolStats, ReportsElementAndByteCounts) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{4}); + const std::size_t stride = sizeof(Tracked); // 4 B, alignment 4 + const std::size_t bookkeeping = 9; // 4 B gen + 1 B alive + 4 B freelist + + laige::PoolStats s = pool.stats(); + EXPECT_EQ(s.capacity, 4u); + EXPECT_EQ(s.inUse, 0u); + EXPECT_EQ(s.peakInUse, 0u); + EXPECT_EQ(s.totalCreated, 0u); + EXPECT_EQ(s.bytesCapacity, 4u * (stride + bookkeeping)); + EXPECT_EQ(s.bytesInUse, 0u); + + ASSERT_TRUE(pool.create(1).ok()); + ASSERT_TRUE(pool.create(2).ok()); + s = pool.stats(); + EXPECT_EQ(s.inUse, 2u); + EXPECT_EQ(s.peakInUse, 2u); + EXPECT_EQ(s.totalCreated, 2u); + EXPECT_EQ(s.bytesInUse, 2u * stride); + EXPECT_EQ(s.bytesCapacity, 4u * (stride + bookkeeping)); + + // A create immediately destroyed: inUse falls back, peakInUse keeps + // the high-water mark, and the churn count (totalCreated) keeps the + // create — G-R4 counts churn, not liveness. + const auto c = pool.create(3); + ASSERT_TRUE(c.ok()); + ASSERT_TRUE(pool.destroy(c.value()).ok()); + s = pool.stats(); + EXPECT_EQ(s.inUse, 2u); + EXPECT_EQ(s.peakInUse, 3u); + EXPECT_EQ(s.totalCreated, 3u); + EXPECT_EQ(s.bytesInUse, 2u * stride); +} + +TEST(PoolStats, ArenaReportsElementAndByteCounts) { + Tracked::resetCounters(); + laige::ArenaPool pool(laige::ArenaPool::Options{4}); + const std::size_t stride = sizeof(Tracked); + laige::PoolStats s = pool.stats(); + EXPECT_EQ(s.bytesCapacity, 4u * stride); + EXPECT_EQ(s.bytesInUse, 0u); + ASSERT_TRUE(pool.create(1).ok()); + s = pool.stats(); + EXPECT_EQ(s.inUse, 1u); + EXPECT_EQ(s.bytesInUse, 1u * stride); +} + +TEST(PoolStats, AlignedElementsUseAlignedStride) { + Tracked::resetCounters(); + laige::Pool pool(laige::Pool::Options{2}); + ASSERT_TRUE(pool.create().ok()); + // sizeof(Padded) == 16 (alignas(16)); the slot stride matches. + EXPECT_EQ(pool.stats().bytesInUse, 16u); + EXPECT_EQ(pool.stats().bytesCapacity, 2u * (16u + 9u)); + EXPECT_EQ(pool.at(pool.create().value()).a, 0.0f); +} + +// --------------------------------------------------------------------------- +// Pool move semantics +// --------------------------------------------------------------------------- + +TEST(PoolMove, MoveTransfersOwnership) { + Tracked::resetCounters(); + laige::Pool a(laige::Pool::Options{3}); + const auto h = a.create(7); + ASSERT_TRUE(h.ok()); + EXPECT_EQ(a.inUse(), 1u); + + laige::Pool b(std::move(a)); + EXPECT_EQ(b.inUse(), 1u); + EXPECT_EQ(b.capacity(), 3u); + EXPECT_TRUE(b.isValid(h.value())); + EXPECT_EQ(b.at(h.value()).value(), 7); + EXPECT_EQ(b.stats().totalCreated, 1u); + // The source is a valid empty pool: nothing is reachable from it. + EXPECT_EQ(a.inUse(), 0u); + EXPECT_EQ(a.capacity(), 0u); + EXPECT_FALSE(a.isValid(h.value())); + EXPECT_EQ(a.get(h.value()), nullptr); + EXPECT_FALSE(a.create(1).ok()); + EXPECT_EQ(a.stats().capacity, 0u); + EXPECT_EQ(Tracked::live(), 1); +} + +TEST(PoolMove, MoveAssignmentTransfersOwnership) { + Tracked::resetCounters(); + laige::Pool a(laige::Pool::Options{2}); + const auto h = a.create(7); + ASSERT_TRUE(h.ok()); + laige::Pool b(laige::Pool::Options{1}); + b = std::move(a); + EXPECT_EQ(b.inUse(), 1u); + EXPECT_EQ(b.capacity(), 2u); + EXPECT_TRUE(b.isValid(h.value())); + EXPECT_EQ(b.at(h.value()).value(), 7); + EXPECT_EQ(a.inUse(), 0u); + EXPECT_EQ(a.capacity(), 0u); + EXPECT_FALSE(a.isValid(h.value())); +}