Locus Lifecycle
A long-running locus executes through a deterministic sequence of runtime-managed phases: birth → run → drain → dissolve. Instead of threading cancellation contexts or manual defer callbacks through functions, you implement lifecycle hooks on the locus and the runtime sequences initialization, steady-state execution, depth-first shutdown cascading, and arena reclamation automatically.
locus Ticker { params { count: Int = 0; limit: Int = 3; }
birth() { println("starting ticker"); }
run() { while !self.draining && self.count < self.limit { println("tick ", self.count); std::time::sleep(500ms); self.count = self.count + 1; } }
drain() { println("stopping intake"); }
dissolve() { println("cleaning up resources"); }}
fn main() { Ticker { limit: 3 };}Lifecycle Phases
Section titled “Lifecycle Phases”Every lifecycle hook is optional; the compiler supplies a no-op default for any phase you omit.
| Phase / Hook | Target | Execution Timing & Purpose |
|---|---|---|
accept(c: Child) |
Parent | Runs after c’s params are initialized and before c.birth(). Admits the child into self.children; cannot reject it. See Parents & Children. |
birth() |
Locus | Runs once at construction after params are initialized and bus subscriptions are registered. Acquire sockets, file descriptors, and buffers here. |
run() |
Locus | Steady-state execution loop. Runs until it returns naturally or shutdown begins. Omitting run() is identical to writing run() { }. |
drain() |
Locus | Runs when shutdown begins (self.draining becomes true). Cascades depth-first through children before running on the parent. Stop accepting new work and finish in-flight operations here. |
release(c: Child) |
Parent | Runs when an accepted flow child’s run() completes, after c.drain() and before c.dissolve(). Marks Child as a per-request flow rather than a long-lived resident. |
dissolve() |
Locus | Runs last during teardown. Release external OS handles acquired in birth(). The runtime frees the locus’s memory arena wholesale immediately after dissolve() completes. |
on_failure(c: Child, err: ClosureViolation) |
Parent | Supervisor hook invoked immediately when a child’s closure, birth_check, or violate statement triggers. See Failure & Supervision. |
locus Server { params { listen_fd: Int = -1; }
birth() { /* acquire sockets, files, buffers */ } run() { /* steady-state main loop */ } drain() { /* stop taking new work; finish in-flight work */ } dissolve() { /* close descriptors acquired in birth() */ }}Instantiation & Teardown Scope
Section titled “Instantiation & Teardown Scope”Hale determines when a locus drains and dissolves from the syntactic position where it is constructed:
| Instantiation Form | Example | Teardown Timing |
|---|---|---|
| Statement position | Ticker { }; or make_ticker(); |
Runs birth → run → drain → dissolve immediately at the end of the statement (fire-and-forget). |
let-bound local |
let t = Ticker { }; |
Born and runs immediately; drain and dissolve are deferred to the exit of the enclosing function scope. Multiple let bindings dissolve in reverse creation order (LIFO). |
| Consumed inline expression | Ticker { }.tick(), serve(Ticker { }), combine(a, make_ticker()) |
Treated identically to a let binding in the enclosing function: born before the expression evaluates and dissolved when the enclosing function exits. |
Owned params child |
params { s: Server = Server { }; } |
Owned by the parent locus; drains and dissolves as part of the parent’s teardown cascade. |
| Returned to caller | return Ticker { }; or return pass(t); |
Ownership transfers to the caller’s receiving binding or expression. |
| Long-lived subscriber | Any locus with bus subscriptions or an active run() |
Remains alive until its owning scope exits so bus handlers can continue firing. |
Factory Calls, Conditionals, and Collections
Section titled “Factory Calls, Conditionals, and Collections”When a function returns a locus handle, the consumer of that call owns the instance:
- Fallible factories: Reaching a factory through
orpreserves ownership rules.let c = std::process::spawn(argv) or raise;closes the child’s pipes and reaps the process when the enclosing function exits. When a fallback builds an alternative locus (Mid { leaf: make_leaf_f() or backup_leaf() }), the parent field owns whichever instance was constructed. - Pass-through and builder functions: Returning a locus parameter (
fn pass(t: Ticker) -> Ticker { return t; }) or wrapping it in a returned parent (fn routes(w: W) -> Routes { return Routes { w: w }; }) transfers ownership to the caller or returned parent locus. - Branching expressions (
if,match, blocks): Ownership is resolved per execution path on the tail expression of each arm. Any locus constructed inside a condition, match scrutinee, or non-final statement of a block is owned by the enclosing function and dissolved at function exit. - Arrays and tuples: In
let ts: [Ticker; 2] = [make_ticker(), make_ticker()];, the enclosing function owns and releases each element at scope exit. In a locus parameter (cs: [C; 2] = [C { }, C { }]orp: (H, W) = both(W { })), the parent locus owns every element: elements drain in index order before the parent and dissolve after the parent’sdissolve(). - Non-locus parameter initializers: If a temporary locus is evaluated purely to compute a scalar parameter (
Lonely { n: Queries { }.total() }),Queries { }is not stored inLonely; it belongs to the enclosing function scope.
Loop Instantiation
Section titled “Loop Instantiation”While a let binding’s storage slot is allocated for the enclosing function (and its variable name is scoped to the enclosing block), a locus constructed inside a while or for loop is reclaimed on each iteration when execution reaches that line again:
while i < steps { let m = zeros(rows, cols); // fresh arena allocated each iteration i = i + 1;} // previous `m` drains and dissolves at the top of the next iteration; // the final instance dissolves when the function returns- The loop holds only one live instance of
mat a time rather than accumulatingstepsarenas. - This per-iteration boundary applies equally to
letbindings, factory calls, and consumed inline literals (Matrix { }.trace(),sum(Matrix { })). A bare statement literal (Matrix { };) still dissolves immediately at the semicolon. - If a single loop iteration binds two loci in sequence, each line reclaims its own previous instance as it is reached—meaning they reclaim in declaration order across iterations (whereas function exit reclaims in reverse order).
hale checkemits a performance warning when a locus is instantiated inside a loop because allocating and tearing down an arena every iteration adds overhead. For hot loops, hoist a single instance outside the loop and reset its state in place.
Early return Teardown
Section titled “Early return Teardown”Exiting a function via an early return (including inside fn main()) drains and dissolves all loci constructed up to that point in reverse creation order:
let store = Store { path: dir };if std::env::args_count() < 2 { println("usage: report <name>"); return 2; // drains and dissolves `store`, then exits with code 2}let report = Report { store: store }; // normal exit dissolves `report`, then `store`A locus constructed only inside a conditional branch is dissolved only when that branch executes.
Child Initialization Order & Blocking run()
Section titled “Child Initialization Order & Blocking run()”A parent locus initializes its params fields strictly in declaration order on the constructing thread:
- Parameter defaults: A parameter’s default expression may read
self.<field>for any parameter declared before it.hale checkrejects reading a parameter declared after it (or reading the parameter itself). - Inline child execution: When a child locus shares the parent’s thread (the default
cooperative(pool = main)), the child’sbirth()andrun()execute immediately when its parameter slot is initialized—before the next sibling parameter is constructed.
If an inline child’s run() contains an infinite loop, subsequent sibling parameters are never born:
locus Server { run() { while true { std::time::sleep(1s); } } // blocks construction thread}locus Metrics { birth() { println("metrics up"); } // never reached}
main locus App { params { server: Server = Server { }; metrics: Metrics = Metrics { }; // never born! }}
fn main() { App { }; }Because Metrics is never born, its bus subscriptions and sockets are never initialized. hale check warns whenever it can statically prove that an inline child on main runs an unbounded while loop before a later sibling parameter.
To prevent a blocking child from starving later siblings:
- Declare the blocking child last in
params:params {metrics: Metrics = Metrics { };server: Server = Server { };} - Place the blocking child on its own thread or pool so
maincan continue constructing subsequent parameters:params {server: Server = Server { };metrics: Metrics = Metrics { };}placement { server: pinned; } // or cooperative(pool = io) - Move coordinator loops into
main’srun(), which executes only after allparamschildren have been born.
Ownership Tree, Borrowing, and Field Replacement
Section titled “Ownership Tree, Borrowing, and Field Replacement”No Recursive Self-Containment
Section titled “No Recursive Self-Containment”A locus cannot contain an instance of itself by value with a default initializer, either directly or through a cycle of locus types (Alpha → Beta → Alpha):
locus Node { params { n: Int = 0; next: Node = Node { n: 1 }; } // error: recursive default construction}Because every default Node would construct another Node, initialization could never terminate. To model collections or linked structures, omit the default (next: Node;), store elements in a @form collection or capacity slot, or use a plain data type.
Owned Children vs. Borrowed Handles
Section titled “Owned Children vs. Borrowed Handles”How a child is supplied to a params field determines whether the parent owns it or borrows it:
- Owned child: Constructed directly in the
paramsinitializer (via a literalLeaf { }, a factorymake_leaf(), anorfallbackmake_leaf_f() or backup_leaf(), aninterfaceslotj: Counter = Churner { }, or aperspectiveslotrouter: perspective(Router) = RouterV1 { }). The child’s lifecycle is tied to the parent’s tree. - Borrowed handle: Passed in by referencing an existing variable or field (
Mid { leaf: shared }, or assigningself.journal = sharedon an interface/perspective slot). The parent’s teardown cascade skips borrowed handles; the scope that originally createdsharedremains responsible for dissolving it.
locus Queries { params { j: Counter = Churner { }; } // owned interface child}locus Gateway { params { router: perspective(Router) = RouterV1 { }; } // owned perspective child}Replacing a Locus Field (Break-Before-Make)
Section titled “Replacing a Locus Field (Break-Before-Make)”Assigning a new locus literal to an existing locus-typed parameter field (or array element self.cs[0] = C { }) triggers a break-before-make lifecycle replacement:
self.conn = Connection { url: next }; // drains and dissolves old conn, then births new connself.conn.url = next; // mutates existing instance in place; no teardown- The runtime drains and dissolves the previous
self.conninstance (releasing its descriptors and children). - The new
Connectionliteral is constructed in the parent’s arena and assigned toself.conn. - The right-hand side of a field replacement must be a locus literal, not a factory call:
self.conn = Connection { url: next }; // valid: constructed directly in parent arenaself.conn = make_connection(next); // compile error: ambiguous ownership between factory frame and field
Graceful Shutdown, self.draining, and terminate;
Section titled “Graceful Shutdown, self.draining, and terminate;”Signal Handling and self.draining
Section titled “Signal Handling and self.draining”Every locus has a synthetic boolean field self.draining (initialized to false). When the process receives SIGINT (Ctrl-C) or SIGTERM, or when main’s run() returns:
self.drainingbecomestrueacross all loci in the program.- Any in-progress
std::time::sleepinsiderun()wakes immediately so loops conditioned onwhile !self.drainingexit without waiting out their timer. (Sleeps executed insidedrain()ordissolve()run for their full duration so final network flushes can pace themselves.) drain()cascades depth-first (deepest children drain first, then their parents).dissolve()executes and arenas are reclaimed leaves-first, after which the process exits with code0.
locus Poller { run() { while !self.draining { std::time::sleep(100ms); } } dissolve() { /* close socket */ }}main locus App { params { api: Gateway = Gateway { }; } placement { api: pinned; } run() { while !self.draining { std::time::sleep(100ms); } }}- Grace period (
LOTUS_DRAIN_GRACE_MS): The runtime allows 5000 ms (configurable viaLOTUS_DRAIN_GRACE_MS) for loops readingself.drainingto finish. If the deadline expires, the runtime logs the stuck pool/worker and loci to stderr and terminates the process. Sending a secondSIGINTterminates immediately. - Immediate exit when idle: If no live locus checks
self.draining(or all suchrun()hooks have already returned and hold no active bus subscriptions),SIGINT/SIGTERMterminates the process immediately without waiting for the grace period. - Default
mainwait behavior: Amain locuswith norun()exits as soon as its children complete, except whenmain(or a child server locus beneath it) holds an activeapi::Exposuresocket, in which casemainimplicitly waits until the exposure stops or the process drains.
Self-Termination (terminate;)
Section titled “Self-Termination (terminate;)”A locus can end its own lifecycle from inside any of its methods by executing terminate;:
fn on_close() { terminate; // exits method immediately and triggers drain -> dissolve}terminate; returns from the current method immediately and schedules the locus for drain() and dissolve().
Runtime Ordering Guarantees
Section titled “Runtime Ordering Guarantees”The compiler and runtime enforce the following execution invariants across the ownership tree:
acceptprecedesbirth: A parent’saccept(c)runs afterc’sparamsare initialized and beforec.birth().- Subscriptions register before
birth; delivery starts afterbirth: Within a single locus literal’s static tree (including nested params,pinnedfields, pool-placed fields, andreplicas = Kshards), all bus subscriptions are reserved before any child’sbirth()runs. Messages published duringbirth()(including self-publishes and messages from earlier sibling params) are queued and delivered in order once the subscriber’sbirth()returns. Elsewhere, dynamically constructing a subscriber after publishing misses the earlier message (hale checkwarns when both occur in the same body). - Pinned
birth_checkthread affinity: On apinnedlocus,birth_checkruns on the locus’s dedicated OS thread betweenbirth()andrun(). - Pool inheritance: A locus stored as a parameter field of a pool-placed locus inherits that cooperative pool for its own
run()and handlers. - Immediate failure routing: A failing closure,
birth_check, orviolatestatement delivers aClosureViolationto the parent’son_failureimmediately. If the parent is still initializing itsparamsblock, the violation is held until all parentparamsare initialized and delivered just before the parent’sbirth(). - Field teardown order: A locus’s owned
paramsfields drain in declaration order (depth-first, each on its own thread/pool) before the owner drains, and each field’s arena is dissolved after the owner’sdissolve()body completes. Pinned fields of amainlocus returned from a helper function remain live until the caller finishes with thatmainlocus. - Dissolve-epoch closures: Closures with
epoch dissolverun immediately beforedissolve(), routing any violation to the parent before cleanup code executes. - Safe field replacement: Replacing an owned field (
self.child = Child { }) runs the old child’sdissolve()before the new child’sbirth(), while keeping the old child’s memory arena alive until any in-flightrun()or pool handler on the old child returns. - Aborted waits on shutdown: When
mainbegins teardown, any publisher on a pool or pinned thread blocked inor wait(or waiting on a fullshm_ringwithon_overflow: block) is woken before thread join and fails withBusWaitAborted. - No restarts during drain: Calling
restart(child)insideon_failurewhile the process is draining (or aftermain’srun()has returned) is a no-op; the child terminates cleanly.
Next: The Bus.