Skip to content

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 { };
}

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 arena

When a parent locus dissolves, its entire subtree of child regions frees wholesale in depth-first order (see Lifecycle & Shutdown).

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)): Declaring release marks Conn as a per-request or per-connection flow. As soon as the child’s run() returns (or it executes terminate;), the runtime dissolves the child and reclaims its arena immediately (see Parents & Children).

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:

  1. 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.
  2. 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

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.

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.

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).

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.

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.