Memory and destruction
Each live managed operand, local, field, and capture has an owner. Moving an affine value clears its source slot. Loading a copyable inline value copies its storage and retains contained strings. Stores stage the new value before releasing the old owner. Source cleanup order is defined by destruction, not reference counts.
Runtime objects share {atomic u64 refs, destroy_callback}. Heap objects begin
with one reference; literals use an immortal count. A
Shared[T] handle is a box whose
count is shared by its source-level owners. Native helpers borrow inputs
and return owned managed results, including retained projections and builder aliases.
Buffers have explicit owners; type metadata is immutable program data.
A class instance owns its fields in a record inside its owner’s slot; the slot’s
value word is the record’s address, which moves relocate. A class field, tuple
component or multi-field variant field of class type is stored flat inside its
owner’s record, so moving the owner copies bytes without relocating that field;
an all-zero flat record marks a moved-out or released field. A one-field class
without __del__ has no record: the instance is its field, so moving it or
moving the field out copies the field’s bits and releases nothing. See
value representations.
Retain uses relaxed atomic updates. Final release acquires preceding releases before destruction; the final thread owns the destruction queue entry. This protects ownership counts, not concurrent mutable payload access or foreign resource transfer. Source permissions still come from borrow and worker checks.
Sole-owner release
Release first loads the count. An immortal count returns. A count of 1 skips the locked decrement: an acquire fence follows the load and the object is destroyed, leaving the dead count at 1 until the destruction queue reuses the word. Other counts take the release decrement.
This is sound because the releaser consumes a counted reference it owns, so the count is at least 1 and other counted references or borrows account for the rest:
- Every count operation by another thread comes from a counted reference or a borrow of one. A borrow of the releaser’s own reference ends before the release, through a scoped-worker join or a job or channel handoff, which orders that thread’s retains before the load. By coherence the load then reads their increments or a later value, so it cannot read a stale 1.
- When the load reads 1, no other counted reference exists, and no new one can appear: creating one needs the releaser’s reference, which it owns exclusively. A retain racing with this release would already race with destruction under an atomic decrement, so borrow and worker checks exclude it.
- The 1 was written by the initial store or by another owner’s release decrement. The acquire fence synchronizes with that decrement, so the other owner’s payload use happens before destruction, as under the atomic path.
Shared[T]handles each own a count, so two live handles keep it at 2 or more. Borrowed contents are released only when the last handle destroys the box or under a mutex.- The destruction queue writes its link only after the release has decided to
destroy, as before. Immortal literals keep
u64::MAXand are never written, so read-only literal data stays valid. Copies into another actor heap check the count only for the immortal value.
Retain has no such path. It also runs through shared borrows, which carry no
count: a scoped worker and its parent copying one string, or two jobs reading
the same Shared[list[str]], can both read 1, and a plain store of 2 would
lose an increment. Retain therefore always uses an atomic update. Compiled
code changes counts only by calling plenty_retain and plenty_release.
An iterative queue destroys owned graphs without recursive native-stack growth.
A record, list, or box being destroyed hands each heap child to the queue and
resumes its walk after that child is gone, which keeps cleanup in declaration
order. A string owns nothing and runs no user code, so the walk frees it in
place instead. A class whose fields own only strings, at any depth and without
__del__, is released in one step from the string offsets its descriptor
records; see value representations.
Inline generator cleanup uses the count handoff but finishes synchronously while
its enclosing storage is live. Private helper aliases do not authorize mutable
source aliases. Traps end the process without unwinding language scopes.
Box destruction
When a box’s content releases without running code, the free order of its children cannot be observed. Its descriptor then carries a compiled release of the content slot, and the runtime calls that function instead of walking the content’s descriptor. The compiler builds one for each boxed type that is:
- a string, collection, box, or another value released with one
plenty_release; - a sum such as
Option[Box[T]]in which one variant owns a single such value; or - a class without
__del__whose fields are of those kinds or own nothing.
The function reads each owned child at its fixed offset and passes it to
plenty_release. During destruction that call only queues the child, so deep
trees still drop without growing the native stack. Content whose release runs
a destructor keeps the ordered walk described above.
Box(value) for content with inline storage, such as a class record, asks
the runtime only for the allocation. Compiled code then copies the content into
the box’s slot. If the allocation fails, the runtime releases the value the box
would have owned. The slot is not zeroed first, just as a local’s stack slot is
not: the copy writes the value word and the payload that word names, such as the
current variant’s fields of a sum, and every reader of the box reads the value
word first and then only that payload. Bytes past a smaller sum variant stay
unwritten and are never read.
Allocator provenance
Runtime objects and container buffers both come from the global allocator, and an object allocation carries no prefix before its header: with only one allocator, freeing needs no record of which one created an object. A box is a 16-byte header, its descriptor word and a padding word, then its content slot. Public allocator selection and scoped allocator lifetimes are unsupported. Adding them (backlog B13) must record provenance again without enlarging objects of the global allocator; a 16-byte prefix made each binary-trees node a 112-byte malloc chunk rather than 96 (issue #814).
Checked buffer growth and partial-construction cleanup implement the
recoverable allocation contract.
The compiler’s runtime-checks feature enables the embedded runtime’s
allocation-checks instrumentation for native integration tests. Allocation-free
regions count successful allocations, including those freed inside the region;
failed attempts do not count. Failure injection is separate. See
runtime validation.