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

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();
},

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:

How stop propagates to children

An exiting actor stops its children first:

  1. Every child gets a nice STOP, with a 1s timeout.
  2. Children that stop are done. Children that refuse are collected.
  3. The exiting actor emits EXIT to its parent, carrying the refusers as orphans.

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:

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.