The lifecycle
Coming from Go? A long-running locus is like a goroutine with structure: instead of go func(){...}() and a context you thread around for cancellation, a…
Coming from Go? A long-running locus is like a goroutine with structure: instead of
go func(){...}()and acontextyou thread around for cancellation, a locus has named lifecycle methods the runtime drives —birth → run → drain → dissolve— and shutdown cascades through the tree automatically. You write the phases; the runtime sequences them.
Until now, loci have been object-like: state plus methods you call. A locus can also run over time. When it does, it moves through a fixed sequence of lifecycle states, and the runtime guarantees the ordering.
The five phases
Section titled “The five phases”locus Server { params { listen_fd: Int = -1; }
birth() { /* acquire: open sockets, files, buffers */ } run() { /* steady-state work — the main loop */ } drain() { /* stop taking new work; finish in-flight */ } dissolve() { /* release what birth acquired */ }}birth()runs once, at construction, after the locus’s state is initialized. Acquire resources here — open a socket, read a file, allocate a buffer. By the time it returns, the locus is live.run()is the steady-state body — typically a loop that serves requests, drains a queue, or ticks on a timer. It runs until it returns on its own or the locus is asked to shut down. A locus that declares norun()has an empty one — the two spellings are the same program — so a flow child with norunis released as soon as itsbirth()completes, exactly as one writtenrun() { }is.drain()runs when shutdown begins: stop accepting new work, let in-flight work finish.dissolve()runs last: release whatbirthacquired. The locus’s memory is freed wholesale right after.
There’s also accept and release for parent/child
relationships — those belong to Parents &
children. And on_failure for
recovery — When things fail.
You only write the phases you need; the compiler supplies no-op
defaults for the rest. A locus with just birth and run is
completely normal.
One rule: a lifecycle body returns no value. These are driven by the runtime, not called by you, so “return a value” has no meaning. A bare
return;ends the body early, exactly as falling off its end does;return <value>is refused.
A simple service
Section titled “A simple service”locus Ticker { params { count: Int = 0; limit: Int = 5; }
run() { while self.count < self.limit { println("tick ", self.count); std::time::sleep(500ms); self.count = self.count + 1; } }}
fn main() { Ticker { limit: 3 }; // runs to completion, then tears down}Order matters: a blocking run() stops later siblings
Section titled “Order matters: a blocking run() stops later siblings”Children are born in declaration order, one at a time, and a
child’s run() runs right there — on the same thread, before the
next sibling is touched. So a child whose run() never returns
means the params after it are never born.
locus Server { run() { while true { std::time::sleep(1s); } } // never returns}locus Metrics { birth() { println("metrics up"); } // never printed}
main locus App { params { server: Server = Server { }; metrics: Metrics = Metrics { }; // never born }}
fn main() { App { }; }Metrics doesn’t just fail to run — its birth() never happens,
so whatever it would have subscribed to or opened simply doesn’t
exist. Nothing errors. The program boots and goes quiet, and the
symptom (“my metrics handler never fires”) points at the bus rather
than at the params block.
Two fixes. Declare the never-returning child last:
params { metrics: Metrics = Metrics { }; server: Server = Server { }; // last}Or give it a thread of its own, which frees the main thread to finish births:
params { server: Server = Server { }; metrics: Metrics = Metrics { };}placement { server: pinned; } // or cooperative(pool = io)Only the blocking child’s placement matters. Pinning the later one doesn’t help — the construction itself happens on the main thread regardless.
The compiler warns when it can prove the shape (a while loop with
no break, on a child that runs on the main thread, with something
declared after it). It can’t prove every loop, so treat the absence
of a warning as “not detected”, not “correct”.
And this is why work that needs its siblings to exist belongs in
the main locus’s run(), not a child’s — main’s run() starts
only once every child has been born.
A locus can’t hold one of itself
Section titled “A locus can’t hold one of itself”A params field is where a locus keeps its children, so this looks like it should be a linked list:
locus Node { params { n: Int = 0; next: Node = Node { n: 1 }; } // won't compile}It isn’t one. The Node that default builds leaves its next to
the same default, which builds another, and nothing ends the chain
— not even the call site, because Node { next: ... } needs a
Node to hand over and building one asks the same question again.
The compiler says so at the param:
param `next` of `Node` defaults to a `Node`; a locus cannot containitself by value — every one the default builds needs another, andno locus literal can end the chain. Drop the default and take thechild from the caller (`next: Node;`), or hold a value rather thana locus.The same error covers a ring through two or three types, and names
it (Alpha → Beta → Alpha). A locus holding a different
locus is the ordinary parent/child shape and is untouched.
What to write instead: take the child from the caller
(next: Node;, supplied at the literal), keep a collection of them
in a form or a capacity slot, or hold a
plain value rather than a locus. A locus is a live thing — an
arena, a lifecycle, maybe a thread of its own — so a self-similar
chain of them is almost always a collection wearing the wrong
clothes.
When does a locus dissolve?
Section titled “When does a locus dissolve?”This is the one piece of bookkeeping worth internalizing,
because it’s how Hale frees resources without a defer or a
finally:
- Statement position (
Ticker { };— no binding, and the value is discarded): the locus runs its whole lifecycle right there and tears down at the end of the statement. Fire-and-forget. let-bound (let t = Ticker { };): it’s born and runs, but drain and dissolve are deferred to the end of the enclosing function’s scope. The binding stays usable for method calls until then.- A literal you use — as a method receiver
(
Ticker { }.tick()), as an argument (serve(Ticker { })), for a field read (Ticker { }.every_ms): the expression that consumes it is the handle, so it behaves exactly like theletform — born before the expression runs, dissolved at the end of the enclosing function. Writing it inline and naming it withletfirst are the same program, whichever position it is in. That is what lets you hand a fresh locus to a service that keeps it:serve(Provider { })is as safe aslet p = Provider { }; serve(p);. - A locus a function hands back (
let t = make_ticker();, or the call used directly —serve(make_ticker())): whatever consumes the handle owns it, so it behaves like the two above. That owner is the expression in that position, and a factory call nested deeper inside it is a handle of its own:let x = combine(a, make_ticker());bindscombine’s result tox, whilemake_ticker()’s result is one nothing names — the enclosing function owns that one and releases it at scope exit. A fallible factory is reached throughor, and that changes nothing:let c = std::process::spawn(argv) or raise;closes the child’s pipes and reaps it when the scope exits, just as a literal would. The two places the frame stays out of it are the places the handle is handed on rather than consumed — written straight into another locus’s field (Router { quick: make("q") }, which the router owns and reclaims with itself, exactly as it would aQuick { }written there) andreturned to your caller (who owns it). - A locus an
if, amatchor a block hands back (return if hot { fast_path() } else { make_ticker() };): those forms have no value of their own — they pass along one arm’s, and exactly one arm runs — so ownership is decided per path. Each arm’s last expression is what is written “in that position” on the path that produces it, and the rule above applies to it unchanged: returned, it is the caller’s on every path; bound withlet, it is released at the enclosing function’s scope exit on every path. A factory called somewhere else inside the form — in the condition, in the scrutinee, in a statement before a block’s last expression — is a handle of its own, and the enclosing function releases it. An ascribed array or tuple is the same story one level out:let ts: [Ticker; 2] = [make_ticker(), make_ticker()];names the array, and each element is a handle the enclosing function releases. - Long-lived (the locus subscribes to the bus, or its
run()hasn’t returned): it stays alive until its scope exits, regardless of binding — it has to, to keep receiving messages.
So let keeps a locus alive for the scope; statement position is
fire-and-forget. When several let-bound loci share a scope,
they dissolve in reverse order of creation (the later one, which
may depend on the earlier, goes first).
Whichever line you’re on, the timing is the timing of the whole
tree the locus owns. A locus you write as another locus’s param
field has no teardown moment of its own — it’s the owner’s, and so
is the one it holds, all the way down. That holds however you
wrote it: a nested literal (Mid { leaf: Leaf { } }), a factory
call (Mid { leaf: make_leaf() }, including make_leaf() or raise), and a param whose default is one, are the same
program. An or with a substitute is too, on both branches:
Mid { leaf: make_leaf_f() or backup_leaf() } reclaims whichever
leaf was actually built, once, with the owner — the substitute is
the field’s, not a leftover of the function that built the owner,
which is what let a Mid you return outlive its own leaf. When the
owner goes, every level’s drain() has run (deepest first), every
level’s dissolve() body has run (outermost first) and every
level’s arena is gone. The exception is a handle you pass in —
Mid { leaf: shared } borrows shared, so the cascade steps over
it at whatever depth it sits, and shared is released once, by the
scope that made it. A call that hands back a locus it didn’t build
is the same borrow, written as a call. So is a handle you store
later: self.journal = shared on a field typed by an interface
or a perspective(P) reclaims whatever the field held until then
and borrows shared, exactly as the literal would have. And an
interface value handed to a field of the same interface — Attempt { performer: self.performer }, both Performer — flows in as itself,
borrowed; no impl is built, and none is owned.
A borrow has to outlive its holder, and hale check refuses the
positions where it cannot: a let of the current frame, or a
handler’s payload, stored into a child of self; a field of self
or a frame let into a literal you return; a let of an inner
block into an outer binding; and a parameter, when any caller hands
it something of the caller’s own frame — the refusal names that call
site. The one allowance is a handle the holder reads only in
birth(): it copies what it needs while the handle is live, and
never touches it again.
This is what a bus handler hands a child that stays. A String, a row or
a payload field is copied into the child’s own storage as it is stored,
so the child can read it long after the handler returned. A
@form(vec) the handler built is a locus: handed to the child by name
it is a borrow, hale check refuses it at the argument, and the shape
that works is the child copying the rows into a @form(vec) of its own
in birth().
How the field is declared doesn’t change the answer. A param
typed by an interface the child satisfies, or by a
perspective(P) it serves, holds an owned child on the same terms
as a locus-typed param — the cascade reaches it and everything
under it:
locus Queries { params { j: Counter = Churner { }; } // an interface slot}locus Gateway { params { router: perspective(Router) = RouterV1 { }; }}Both children go when their holder goes, and so does whatever they
hold. Designating a different impl at the literal (Gateway { router: RouterV2 { } }) reclaims the one you actually built.
A factory reads the same way in an interface slot — Queries { j: make_churner() }, make_churner() or raise included. The
function’s declared return names the impl, so the holder reclaims
exactly what the factory built, whether or not that is the impl the
param’s default names.
One shape that is not a transfer: a locus written inside the
initializer of a param that can’t hold a locus. In Lonely { n: Queries { }.total() } the Queries is just an expression — it has
no field to live in — so it belongs to the enclosing function’s
scope, exactly as if you had written it on a line of its own.
The lifetime is the enclosing function, not the enclosing block —
the slot a let binds lives until the function exits, including
after the loop that bound it — while the name is the block’s: a
let is visible to the end of its block, and one that reuses a
parameter’s or an outer let’s name inside a branch shadows it there
only. But a locus created in a loop is reclaimed
when the next iteration reaches the same line:
while i < steps { let m = zeros(rows, cols); // a fresh arena every iteration… i = i + 1;} // …each one released as the next // replaces it; the last at returnComing back round to that line is the end of the previous m’s life:
it runs its drain() and dissolve() and gives back its arena before
the new one takes its place, and the function’s exit releases the last
one — the slot outlives the loop, though the name m is the loop
body’s and is not readable after it. The loop holds one
instance at a time, not steps of them. Both spellings behave
identically — a factory call, a Matrix { } literal and a literal you
merely use (Matrix { }.trace(), sum(Matrix { })) all get the
same boundary.
Each line reclaims its own previous instance, where it stands, so two
loci bound in one iteration are released in the order they were
written — the reverse of the newest-first order at function exit. They
are independent either way; it only shows if one’s dissolve() reads
a handle it borrowed from the other, which would see the next
iteration’s instance.
The compiler still warns about the shape, because a fresh arena per iteration is real work in a hot loop even when it is reclaimed. To spend nothing, hoist one instance out of the loop and refill it.
Dropping the binding changes where the reclaim lands, not whether
one happens: a bare Matrix { }; statement is reclaimed at the
statement, while Matrix { }.trace() and sum(Matrix { }) both use
the literal, so each lives to the top of the next iteration — and the
last of them to the end of the function — exactly as the binding did.
Early return is an exit, not a shortcut
Section titled “Early return is an exit, not a shortcut”“The end of the enclosing function” means whichever way the function ends. A guard that returns early tears down everything alive at that point, and it takes nothing away from the ordinary exit:
let store = Store { path: dir };if std::env::args_count() < 2 { println("usage: report <name>"); return 2; // dissolves `store`, exits 2}let report = Report { store: store }; // ordinary exit: `report`, then `store`Both endings are complete. The guarded one dissolves store; the
one taken when the guard does not fire dissolves report and
then store, newest first as always. A locus bound only inside
the branch that returns dissolves on that branch alone — the other
endings never built it, so there is nothing for them to release.
This holds in fn main too, which matters because main is where
usage checks and flag guards live.
Replacing a locus held in a field
Section titled “Replacing a locus held in a field”If a locus holds another locus in a field — say a server that
keeps its current connection in self.conn — assigning a fresh
one replaces a live thing, so it’s a lifecycle event, not a
plain store:
self.conn = Connection { url: next }; // reconnectHale tears the old self.conn down first (drain → dissolve, so
its socket and any children are released), then builds the new
one into this locus’s arena and points the field at it. The old
and new never overlap, and the new instance lives until the
parent dissolves — no manual close, no leak. This is
break-before-make: if you need make-before-break (hold the old
connection open while the new one warms up), keep both in
separate fields and swap explicitly.
To reconfigure the same instance instead of replacing it, mutate
in place — self.conn.url = next; — which keeps the connection
and triggers no teardown.
One rule comes with this: the right-hand side has to be a literal.
self.conn = Connection { url: next }; // fine — built in placeself.conn = make_connection(next); // errorThe second line looks reasonable and is rejected on purpose. Two
things would claim that connection — the field, which tears it down
when this locus dissolves, and the function that built it, which
tears down what it made. The language has no way to pick, so it asks
you to. Build it here with a literal, or if the thing genuinely
belongs to you as a child rather than a field, take it through
accept.
This is the same idea as a method not being allowed to return a
locus: a locus is structure, not a value you pass around. Plain
let-bound loci are unaffected — a factory result you bind and use
locally is owned by that binding, and that’s fine.
Shutdown cascades
Section titled “Shutdown cascades”drain() is always depth-first cascading. Calling it on a
locus first drains all of its children (and theirs, recursively),
waits for them, then drains itself, then dissolves. You never
write a manual teardown walk.
This is what makes Ctrl-C trivial: SIGINT (or SIGTERM) drains the
whole program. Every locus’s self.draining turns true, a
sleep returns early, and a run() that loops on the flag ends —
then the tree dissolves in dependency order, leaves first, and the
process exits 0:
locus Poller { run() { while !self.draining { // ... one unit of work ... std::time::sleep(100ms); } // flush what the peer has not acknowledged yet } dissolve() { /* release the socket */ }}A long-lived loop — a broker connection, a websocket client, a
pinned receive loop — writes its condition as !self.draining, and
that is all it takes to shut down properly. Only a wait that was
already running when the signal came is cut short; a sleep in your
drain() or dissolve() that paces a final flush gets its full
time. A run() that never checks the flag cannot be stopped this
way; the runtime gives the drain five seconds (LOTUS_DRAIN_GRACE_MS)
and then lets the signal end the process as it normally would — after
printing one line that names what it was still waiting on: the pool
and the locus a worker was inside, and the loci that read the flag and
had not ended — and a
second Ctrl-C ends it at once. The drain only happens while something
live can answer it: if no locus that checks the flag has been built —
say the only !self.draining loop is in a package you import but
don’t use — the signal ends the program at once, with no grace to wait
out. Under hale run the same holds:
hale waits for the drain instead of dying first, and passes a
SIGTERM sent to it on to the program.
A program’s life is the life of its run()s, not just main’s:
if main’s run() returns while a pinned (or cooperative) child’s
loop is still going, the process keeps running until that loop ends
— which is how a server stays up — and SIGTERM is how you end it. A
child on an async_io pool is different: when main’s run() ends,
its pool shuts down and a run() parked there is abandoned.
The order, rule by rule
Section titled “The order, rule by rule”The runtime keeps the order without a state machine: the compiler emits each step where it belongs, and a torn-down locus is marked so nothing tears it down twice. The rules that order gives you:
acceptsees the params, not the birth. A parent’saccept(c)runs once the child’s params are built and before the child’sbirth(). It admits the child; it cannot turn it away.- Subscriptions come before
birth(); delivery after it. A locus can publish to its own topics frombirth()and hear it, oncebirth()has returned: nothing published to a new locus is delivered while itsbirth()runs. What arrives meanwhile, its own sends included, waits and is delivered afterwards, in order. Once a queue’s worth is waiting, a publisher elsewhere waits for the birth to finish; a handler on the new locus’s own pool, or on its pinned thread, never does. - A pinned locus checks its birth on its own thread. Its
birth_checkruns there, afterbirth()and beforerun(). - A field inherits its owner’s pool. A locus field inside a
field placed on a cooperative pool runs its
run()on that pool, as its owner does. - A failure is a
ClosureViolation, delivered when it happens. A failing birth closure,birth_check,violateor closure reaches the parent’son_failureat that moment, not at the child’s dissolve. While the parent is still building its own params the failure is held and delivered once they are all set, before the parent’sbirth(). The failed child stays readable in the handler, andrestartreuses it. - Fields drain first, in declaration order. A locus’s own locus
fields drain before it does, one after another in the order they
are declared, each after its own fields; a pinned locus’s on its
thread, and a field typed by an interface or a perspective like
any other. Each is dissolved after its owner’s
dissolve(). A field lives as long as its owner: a pinned field of amainthat a function builds and returns keeps its threads, one per replica, until the caller is done with thatmain, not until the function returns. - Dissolve-epoch closures run before
dissolve(). A violation there reaches the parent before your cleanup runs. - Replacing a field dissolves the old child before creating the new
one. A started run keeps the old child’s storage and owned
descendants alive until it returns, and so does a bus handler the
old child is running on its pool. If a queued main-thread handler
performs the replacement, storage release waits until that handler
returns, so another queued handler can answer the old run. Cleanup
in
dissolve(), including closing handles, still happens before the replacement’s birth, except for a failed child its parent has not heard yet: that one is torn down right after its handler runs. - A waiting publisher does not hold the exit. A publisher on a
pool or on a pinned thread waiting in
or waitfor room on a queue onlymaindrains is woken when the program tears down, before its thread is joined, and its publish fails withBusWaitAborted; it is not counted as sent. This holds howevermainis torn down, amainbuilt and torn down in a function other thanfn mainincluded. Ashm_ringreader in the same program stops after the pools are joined and before any locus is torn down; a publish still waiting for room on itson_overflow: blockring then fails the same way. - Ctrl-C raises a flag. The signal calls none of your methods;
the
run()s that watchself.drainingreturn, and the ordinary teardown follows. - No restart during a drain. A handler that asks for
restartwhile the process drains gets no restart: the child ends as if the handler had returned without asking.
A few rules are decided and not yet true of every program;
spec/runtime.md § “Lifecycle obligations” names each one, and
the compiler’s test suite carries a program that shows today’s
behaviour until it changes:
- a restart asked for while
main’s exit is already joining the pools still runs; - a dissolve-epoch violation of a flow child, or of a field torn
down from
fn main, ends the process instead of reaching the parent’s handler; - a subscribing locus that only stdlib or imported code builds hears
what is published to it during its
birth()at once, not after; - a
birth()that publishes to its own topic without bound keeps every message waiting until memory runs out (whether such a birth meets a bound or a refusal is not decided).
The lifecycle is the skeleton of every long-running Hale program. Next, the thing those programs use to talk to each other: The bus.