Memory & Lifetime
Hale manages memory through hierarchical region allocation rather than garbage collection or compile-time borrow checking. Every locus owns an arena allocator: intermediate allocations inside methods use a per-call scratch region that resets on return, while values stored on self live in the locus’s arena and free in one shot when the locus dissolves.
type Route { path: String; target: String; }type Session { id: Int; user: String; }
locus Worker { }
locus Router : projection chunked { capacity { heap routes of Route; pool sessions of Session; } accept(w: Worker) { }}
fn main() { Router { };}Region Hierarchy & Locus Arenas
Section titled “Region Hierarchy & Locus Arenas”Every locus instance owns an arena—a contiguous bump-allocated memory region. Strings, records, and collection buffers created and persisted by the locus allocate from this arena. Individual objects are not freed one by one; when the locus dissolves, its entire arena is reclaimed at once.
Arena regions nest alongside the locus tree. A child locus allocates from a sub-region of its parent:
root└── App arena └── Server arena ├── Conn A arena └── Conn B arenaWhen a parent locus dissolves, its entire subtree of child regions frees wholesale in depth-first order (see Lifecycle & Shutdown).
Flow-Child Reclamation (release)
Section titled “Flow-Child Reclamation (release)”When a long-running parent accepts child loci via accept(c: Conn), child lifetime depends on whether the parent declares release(c: Conn):
- Resident children (default): Without
release, an accepted child’s arena lives until the parent locus dissolves. - Flow children (
release(c: Conn)): DeclaringreleasemarksConnas a per-request or per-connection flow. As soon as the child’srun()returns (or it executesterminate;), the runtime dissolves the child and reclaims its arena immediately (see Parents & Children).
Structural Memory Safety
Section titled “Structural Memory Safety”Garbage collectors and borrow checkers exist to determine when memory can be safely freed without creating dangling pointers. Hale enforces two structural invariants that make wholesale arena reclamation sound without runtime tracing or lifetime annotations:
- Vertical-only pointer flow: References follow parent-to-child hierarchy (
H9). A value inside one locus’s arena is never referenced directly by a sibling locus, so freeing a locus region never leaves dangling pointers in peers. - Copy-on-delivery messaging: Bus payloads crossing a locus boundary are copied into a delivery-scoped buffer that is freed as soon as the subscriber handler returns. If a handler persists a field (
self.last = msg.body), the runtime deep-copies that value into the receiver’s own arena. Sender and receiver lifetimes remain completely independent.
Per-Method Scratch Regions & Field Retirement
Section titled “Per-Method Scratch Regions & Field Retirement”Not every allocation inside a locus belongs to its long-lived arena:
| Allocation Scope | Where It Allocates | When It Frees |
|---|---|---|
| Method / handler transient values | Per-activation scratch sub-region | Immediately when the method or handler returns |
| A method’s loop body that keeps nothing past its iteration | Per-iteration region | When the next iteration starts, or the loop is left |
Scratch-local free fn |
Function-local scratch region | Immediately when the free function returns |
Non-scratch free fn |
Caller’s active region | When the caller’s activation or scope ends |
Persisted field (self.field = val) |
Locus lifetime arena | When the locus dissolves (or on field replacement retirement) |
Stack-local scalar array ([T; N]) |
CPU stack frame (up to 8 KiB) | When the function frame returns |
Per-Iteration Loop Regions
Section titled “Per-Iteration Loop Regions”A loop in a method, a handler or a lifecycle hook (run() included) frees what each iteration makes when the iteration ends, so a run() that loops for its locus’s whole life holds one iteration’s values at a time, not every one it ever made:
run() { let body = std::http::BodyReader { req: request }; let h = std::crypto::Sha256 { }; while true { let c = body.chunk() or { break; }; // freed when the iteration ends h.update(c); }}Each iteration gets a region of its own when the loop body keeps nothing past the iteration. The one thing that can is a binding declared before the loop: a body that assigns such a binding a value that is not a scalar or a locus reference (last = c, acc = acc + s, arr[i] = s) runs on the method’s scratch as before, and so does a body that hands a call anything but the iteration’s own values, scalars and bare locus references (an outside record, a field of self or of another locus, an element of an outside array: a free function, a function value held in a field or a method could write into it), and a body that makes a locus or uses a bounded[T; N] or BytesMut declared outside it. The downloader’s calls (body.chunk(), h.update(c), out.write_bytes(c)) are fine, and so are builtins that only read (len, println). Everything else an iteration hands on keeps the lifetime it has anyway: a value stored into self is copied into the locus, a sent value into the payload arena, a returned one into the caller’s. hale verify names the call or literal of a loop that still keeps one value per iteration. Free functions and main keep their loops on the frame’s region.
Whole-Field Replacement Retirement
Section titled “Whole-Field Replacement Retirement”When a method or bus handler overwrites a field on self (self.latest = Thing { ... } or self.state = State::Ready(name)), the struct bytes overwrite in place and any heap blocks owned by the displaced value—such as Strings, Bytes, or nested records, tuples, and arrays—are retired at the activation boundary and recycled on subsequent stores into self.
For String and Bytes fields whose lengths vary across writes, retired blocks are retained at their size-class capacity and reused by future stores. As a result, repeated whole-field replacement inside handlers or methods keeps the locus arena flat at the high-water mark of the largest stored value.
Capacity Slots (heap and pool)
Section titled “Capacity Slots (heap and pool)”While the default locus arena handles general locus-lifetime state, loci that manage structured collections declare explicit capacity slots:
type Route { path: String; target: String; }type Session { id: Int; user: String; }
locus Router { capacity { heap routes of Route; // growable buffer, cells freed individually pool sessions of Session; // bounded population of fixed-size recyclable cells }}| Slot Kind | Storage Discipline | Typical Use |
|---|---|---|
heap <name> of <T> |
Growable contiguous or node storage; elements can be resized and reclaimed during the locus’s lifetime, and the entire slot frees at dissolve. | Backing slot for @form(vec) and @form(heap). |
pool <name> of <T> |
Fixed-shape recyclable cell pool supporting fast acquire, release, and indexed lookup. | Backing slot for @form(hashmap), @form(set), @form(ring_buffer), and @form(lru_cache). |
Collection annotations such as @form(vec) and @form(hashmap) synthesize their typed method surfaces directly over these capacity slots (see Forms Under the Hood). For fixed-capacity lists embedded directly inside a value type rather than a locus, use bounded[T; N] (see Collections).
Projection Classes
Section titled “Projection Classes”When a parent locus accepts child loci, its projection class declares the observation resolution and population scale for those children, allowing the compiler to select an appropriate child-arena allocator:
| Projection Class | Population Scale | Observation Model | Allocator Strategy |
|---|---|---|---|
rich |
~4–10 children | Individual named children, fully inspected | Dedicated per-child arenas, low churn |
chunked (default) |
~10–30 children | Range-based observation and moderate churn | Per-child sub-regions with free-list reuse |
recognition(cap = N, fixed_cell) |
~100–500+ children | Aggregate observation (counts, histograms) | Pre-allocated fixed-cell pool with hard runtime capacity N |
locus Worker { }
locus Fleet : projection recognition(cap = 200, fixed_cell) { accept(w: Worker) { } release(w: Worker) { }}Changing a locus’s projection class alters the underlying region layout without changing parent or child method code.
Sizing Hints vs. Lifetime Rules
Section titled “Sizing Hints vs. Lifetime Rules”Declared arena sizes are allocation hints: if a locus arena exceeds its initial chunk budget, the runtime attaches an additional chunk rather than panicking. The sole hard capacity limit is the cap = N parameter on projection recognition, which enforces a strict cell ceiling at runtime.
One allocation shape bypasses arenas entirely: a fixed-size scalar array literal bound to a local variable (let mut counts: [Int; 16] = [0; 16];) and accessed solely by index (counts[i]) is allocated directly on the CPU stack frame (up to 8 KiB per function). See Performance for details on keeping hot-path memory flat.