Lifecycle
How an actor starts, runs, and — most importantly — 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 not special-cased into the runtime — it is just another 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
When an actor exits, it does not just vanish — it 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.
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.