Skip to main content

Lifecycle

Every actor in Nexus follows a well-defined lifecycle expressed as a state machine. Transitions are enforced at runtime — invalid transitions throw InvalidActorStateTransition.

Actor states

Figure 1: The actor state machine. Suspended and Running are the only states that accept user messages. Stopped is terminal.

StateDescription
NewActor constructed but not yet started.
Startingstart() called; setup behavior resolving.
RunningActor processing messages from its mailbox.
SuspendedActor paused; user messages are deferred and replayed in order on Resume, system messages still dispatch.
StoppingShutting down; children receive PoisonPill, PostStop delivers.
StoppedTerminal. Mailbox closed. No further messages processed.

Lifecycle signals

Signals are delivered to an actor's signal handler at key lifecycle moments. All signals implement Signal.

SignalWhen it fires
PreStartAfter the actor reaches Running, before any user messages
PostStopAfter the actor enters Stopping, before the mailbox closes
PreRestartBefore the actor restarts due to a supervision decision
PostRestartAfter the actor restarts with a fresh behavior
ChildFailedWhen a child actor throws an unhandled exception
TerminatedWhen a watched actor stops, regardless of reason
ReceiveTimeoutWhen no user message arrives within the configured duration

Attach a signal handler with ->onSignal():

src/Actor/SignalHandlerExample.php
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Lifecycle\Signal;
use Monadial\Nexus\Core\Lifecycle\PreStart;
use Monadial\Nexus\Core\Lifecycle\PostStop;
use Monadial\Nexus\Core\Lifecycle\Terminated;

$behavior = Behavior::receive(
fn (ActorContext $ctx, object $msg): Behavior => Behavior::same(),
)->onSignal(function (ActorContext $ctx, Signal $signal): Behavior {
return match ($signal::class) {
PreStart::class => handleStart($ctx),
PostStop::class => handleStop($ctx),
Terminated::class => handleTerminated($ctx, $signal),
default => Behavior::same(),
};
});

Return Behavior::same() from the signal handler to keep the current behavior. Return Behavior::stopped() to initiate shutdown.

Graceful shutdown sequence

ActorSystem::shutdown() is deadline-driven. It works top-down through the supervision tree.

Figure 2: The graceful shutdown sequence. PoisonPill propagates down the tree; PostStop delivers bottom-up before the runtime releases resources.

The shutdown steps in order:

  1. Marks the system as stopping (idempotent).
  2. Sends PoisonPill to every top-level child.
  3. Yields cooperatively until every child has stopped or the deadline expires.
  4. Force-stops any survivors by closing their mailbox, which unblocks the message loop so PostStop delivers naturally.
  5. Signals the runtime to release timers and handles.

Lifecycle example

An actor that acquires a database connection on start and releases it on stop:

src/Actor/DatabaseWorker.php
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Lifecycle\Signal;
use Monadial\Nexus\Core\Lifecycle\PostStop;

function databaseWorker(ConnectionPool $pool): Behavior
{
return Behavior::setup(function (ActorContext $ctx) use ($pool): Behavior {
$conn = $pool->acquire();
$ctx->log()->info('Connection acquired');

return Behavior::receive(
function (ActorContext $ctx, object $msg) use ($conn): Behavior {
if ($msg instanceof Query) {
$result = $conn->execute($msg->sql);
$ctx->reply($result);
}

return Behavior::same();
},
)->onSignal(function (ActorContext $ctx, Signal $signal) use ($pool, $conn): Behavior {
if ($signal instanceof PostStop) {
$pool->release($conn);
$ctx->log()->info('Connection released');
}

return Behavior::same();
});
});
}

ReceiveTimeout

ReceiveTimeout fires when no user message arrives within a configured duration. Arm it from any handler with $ctx->setReceiveTimeout().

src/Actor/PassivatingActor.php
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Lifecycle\ReceiveTimeout;
use Monadial\Nexus\Runtime\Duration;

$behavior = Behavior::setup(static function (ActorContext $ctx): Behavior {
$ctx->setReceiveTimeout(Duration::seconds(120));

return Behavior::receive(
static fn (ActorContext $ctx, object $msg): Behavior => Behavior::same(),
)->onSignal(static function (ActorContext $ctx, object $signal): Behavior {
if ($signal instanceof ReceiveTimeout) {
return Behavior::stopped();
}

return Behavior::same();
});
});

The timer resets on every user message. System messages (Watch, Unwatch, PoisonPill) do not reset. Call $ctx->setReceiveTimeout(null) to disable. See Passivation for the full pattern.

System messages

System messages are handled by the actor infrastructure before user-defined handlers see them.

MessageEffect
PoisonPillGraceful stop after the current message. Delivers PostStop.
KillImmediate stop. No further messages processed.
SuspendTransitions to Suspended. User messages are deferred (buffered in arrival order), not processed.
ResumeTransitions from Suspended back to Running and replays the deferred user messages in order — nothing is lost.

System messages are dispatched before the running-state guard, so a Suspended actor still handles Resume and PoisonPill (it can always be resumed or stopped). Only user messages and signals are deferred while suspended. | Watch | Registers a watcher to receive Terminated when this actor stops. | | Unwatch | Removes a previously registered watcher. |

Death watch and child cleanup

ActorContext::watch($ref) subscribes the calling actor to the target's termination. When the target stops — for any reason, including being stopped as part of its parent's shutdown — every watcher receives a Terminated($ref) signal through its behavior's signal handler. unwatch($ref) cancels the subscription; a subsequent stop delivers nothing. Watching an actor that has already stopped delivers Terminated immediately, so there is no race between spawn()/stop() and watch().

The same mechanism cleans up the supervision tree. When a child stops, its parent deregisters it: the child is pruned from the parent's children map, so ActorContext::child($name) returns null and children() no longer lists it, and the name becomes reusablespawn($props, $name) with a previously used name succeeds once the prior child has stopped (a live name still throws ActorNameExistsException). This makes passivation and respawn patterns work: stop an idle child and re-spawn it under the same name on demand.

Death watch and child cleanup behave identically on the Fiber and Swoole runtimes — the notifications travel as ordinary mailbox messages, so the same actor code observes the same Terminated/lookup/reuse contract on both.

Failure modes

Lifecycle failures are usually caused by exceptions during setup or by incorrect shutdown ordering.

SymptomCauseRecovery
ActorInitializationException on startupBehavior::setup() factory threw before returning the initial behaviorFix the factory; the exception is not retried — the actor goes to Stopped
InvalidActorStateTransition thrownA lifecycle operation was applied to an actor in an incompatible stateThis is a framework or integration bug; investigate the spawn/shutdown sequence rather than catching the exception
PostStop not delivered; resources not releasedActor was force-stopped because the shutdown deadline elapsed before graceful drainIncrease the Duration passed to shutdown(), or reduce the work in handlers to drain faster
Terminated signal never received by watcherwatch() was called after the target already stoppedCall watch() immediately after spawn(); a Terminated for a pre-stopped actor is still delivered if you watch it
Child actors still running after shutdown() returnsChildren have infinite loops or blocked I/O that prevent PostStop from completingEnsure all blocking calls are interruptible; use Duration timeouts on external I/O inside handlers

Next steps