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 a context you 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 */ }
}

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 contain
itself by value — every one the default builds needs another, and
no locus literal can end the chain. Drop the default and take the
child from the caller (`next: Node;`), or hold a value rather than
a 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:

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 return

Coming 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 }; // reconnect

Hale 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 place
self.conn = make_connection(next); // error

The 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:

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:

The lifecycle is the skeleton of every long-running Hale program. Next, the thing those programs use to talk to each other: The bus.