Lifecycle
How an actor starts and runs, and what happens when it stops. This page covers the hook order, the cooperative stop protocol, force-stop, and what happens to children and grandchildren when an actor goes down.
The hook order
Every defineActor runs its hooks in a fixed order:
beforeStart → setup → afterStart → [message loop] → beforeEnd → afterEnd
beforeStart—thisexists, but state is not set yet. Register things before forking children.setup(args)— return the initial state. Fork children here.afterStart— state is ready.- message loop —
handlers,onMessage,onChildExitandonOrphanfire here, one message at a time. beforeEnd(reason)— the loop has finished, with the exit reason.afterEnd(reason)— final teardown.
Stopping, nicely
Stopping is cooperative. A STOP message arrives and the actor decides
whether to accept it:
onStopRequested() {
// finish up, flush, clean up…
this.agreeToStop();
},
- If
onStopRequestedis defined, it runs onSTOP. Callthis.agreeToStop()to accept; if you don't, the actor keeps running. - If
onStopRequestedis not defined, aSTOPstops the actor immediately.
this.exit(reason?) skips the dance and stops right away. In every case the
beforeEnd / afterEnd hooks run (with the exit reason), and the actor emits
EXIT to its parent.
STOP is a message
STOP is an ordinary message in the inbox, processed in the same serial queue
as everything else, one message at a time. If the actor is blocked inside a handler (say, awaiting a slow
promise), the STOP waits in the buffer until that handler settles. A stuck
actor never reaches its STOP, which is exactly why force-stop exists.
Force stop
Force-stop is the escalation for an actor that won't cooperate:
stop({ force: true })— tries the nice way first; if the actor has not exited within a 1s timeout (itsSTOPmay still be queued behind a stuck handler), it firesgenerator.return(), cascades force-stop to children, then hard-kills.forceStop()— hard-kills outright: the generator is abandoned (itsfinallynever runs, noEXITis emitted), the inbox is dropped, andwait()/ready()settle.
How stop propagates to children
An exiting actor stops its children first:
- Every child gets a nice
STOP, with a 1s timeout. - Children that stop are done. Children that refuse are collected.
- The exiting actor emits
EXITto its parent, carrying the refusers asorphans.
So a tree tears down from the top: the parent stops its children, who stop their own children, and so on.
Hook order across the tree
When a tree tears down, every actor's beforeEnd and afterEnd runs — but the
two hooks move in opposite directions:
beforeEndruns top-down. An actor'sbeforeEndfires before it stops its children, so the root's runs first, then its children's, then theirs.afterEndruns bottom-up. An actor'safterEndfires only after its children have fully stopped and after it has emittedEXIT, so leaves run first, then parents, then the root.
For one actor the local order is:
beforeEnd → stop children (recursively) → emit EXIT → afterEnd
beforeEnd is the moment to finish your own work before the subtree below you
goes down. afterEnd is the moment when that subtree is already gone and your
EXIT is on its way to your parent.
Orphans
An orphan is a process whose parent exited while it was still running (it
refused to stop). It is handed up in the parent's EXIT, and the grandparent
decides its fate with onOrphan:
onOrphan(orphan) {
return "adopt"; // see the table below
},
| policy | effect |
|---|---|
"adopt" |
promote the orphan to a child; its buffered messages are drained |
"force-stop" |
hard-kill it (the default when onOrphan is not defined) |
"unparent" |
drop its buffer, keep it running in orphans |
"leave" |
keep buffering, propagate it up on your own exit |
A tree that outlives its parent
A worker forks a helper that refuses to stop. When the worker dies, the helper becomes an orphan and the supervisor adopts it:
import { defineActor, defineMessages } from "posipaki";
// Refuses to stop — it will outlive its parent and become an orphan.
const helper = defineActor({
name: "helper",
inMessages: defineMessages<never>(),
outMessages: defineMessages<never>(),
setup: () => ({}),
handlers: {},
onStopRequested() {
// no agreeToStop() here — keep running
},
});
const worker = defineActor({
name: "worker",
inMessages: defineMessages<{ type: "DIE" }>(),
outMessages: defineMessages<never>(),
async setup() {
await this.fork(helper, null);
return {};
},
handlers: {
DIE() {
this.exit("done");
},
},
});
const supervisor = defineActor({
name: "main",
inMessages: defineMessages<{ type: "DIE" }>(),
async setup() {
const workerProc = await this.fork(worker, null);
return { worker: workerProc };
},
handlers: {
DIE(msg) {
this.state.worker.send(msg);
},
},
onOrphan(orphan) {
// The helper survived worker's exit. Take it over.
return "adopt";
},
});
const proc = await supervisor.spawn(null);
await proc.ready();
proc.send({ type: "DIE" });
await proc.wait();
An actor that never emits can declare outMessages: defineMessages<never>() —
the EXIT/STOP frames are framework messages and don't count as out-messages.