Core Concepts
Actor Lifecycle
Actors go through distinct phases from creation to shutdown. Understanding the lifecycle helps you initialize resources, clean up properly, and debug actor behavior.
Lifecycle Overview
Lifecycle Hooks
Hooks let you run code at specific points in the actor's life:
actor
.before_start(|actor| {
println!("Preparing to start...");
Reply::ready()
})
.after_start(|actor| {
println!("Started and ready for messages!");
Reply::ready()
})
.before_stop(|actor| {
println!("About to stop...");
Reply::ready()
})
.after_stop(|actor| {
println!("Fully stopped.");
Reply::ready()
});
Hook Timing
When to Use Each Hook
| Hook | Runs | Use For |
|---|---|---|
before_start | Inside start().await, before the actor task is spawned | Logging, sync validation (cannot send messages yet) |
after_start | As the actor task begins, immediately before the message loop | Async init, send messages, notify others, start timers |
before_stop | When a graceful stop is requested (skipped on forced shutdown) | Cleanup, save state, flush buffers |
after_stop | After the loop exits and children have stopped (always runs) | Final logging, assertions in tests |
before_start cannot send messages
The message loop isn't active during before_start. If you need to send initialization messages (including to yourself), use after_start instead.
before_start
Runs before the actor starts processing messages. The message loop is not active yet, so you cannot send or receive messages.
actor.before_start(|actor| {
println!("Actor {} is starting", actor.id());
// Synchronous validation or logging
if actor.model.some_field.is_empty() {
tracing::warn!("Actor starting with empty field");
}
Reply::ready()
});
Common uses:
- Logging startup
- Synchronous validation
- Reading environment variables
Don't use before_start for async initialization
Messages sent during before_start won't be processed until the loop starts. For async initialization that requires messaging, use after_start instead.
Custom Default for Complex State
If you need to initialize state that doesn't implement Default, use #[acton_actor(no_default)] and provide your own Default implementation:
use std::io::{stdout, Stdout};
#[acton_actor(no_default)]
struct Printer {
out: Stdout,
}
impl Default for Printer {
fn default() -> Self {
Self { out: stdout() }
}
}
This is cleaner than trying to initialize in lifecycle hooks.
after_start
Runs after the message loop has started. This is where you can safely send messages, including to yourself.
actor.after_start(|actor| {
let self_handle = actor.handle().clone();
Reply::pending(async move {
// Start a periodic timer
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
self_handle.send(Heartbeat).await;
}
});
})
});
Common uses:
- Starting periodic tasks
- Async initialization that requires messaging
- Sending initial messages
- Notifying other actors of startup
Async Initialization Pattern
For resources that require async setup (like database connections), initialize in after_start:
#[acton_message]
struct SetConnection(Connection);
actor
.after_start(|actor| {
let self_handle = actor.handle().clone();
Reply::pending(async move {
let conn = Database::connect("...").await.unwrap();
self_handle.send(SetConnection(conn)).await;
})
})
.mutate_on::<SetConnection>(|actor, ctx| {
actor.model.connection = Some(ctx.message().0.clone());
Reply::ready()
});
before_stop
Runs when shutdown is requested, before the message loop ends:
actor.before_stop(|actor| {
println!("Stopping actor {}", actor.id());
// Sync cleanup
actor.model.active = false;
// Or async cleanup
let db = actor.model.db_connection.clone();
Reply::pending(async move {
db.flush().await;
})
});
Common uses:
- Flushing buffers
- Saving state to disk
- Notifying dependencies
- Closing connections gracefully
after_stop
Runs after the actor has fully stopped:
actor.after_stop(|actor| {
println!("Actor {} final state: {:?}", actor.id(), actor.model);
// Great for test assertions
#[cfg(test)]
assert_eq!(actor.model.processed_count, 10);
Reply::ready()
});
Common uses:
- Final logging
- Test assertions
- Cleanup verification
Startup Sequence in Detail
When you call start().await:
Key points:
- The actor's MPSC channel is created earlier, when
new_actor()builds the actor before_startruns inline insidestart().await, in the caller's task, before anything is spawnedstart()then spawns a Tokio task for the actor and returns the handle immediatelyafter_startruns as the actor task begins, immediately before the message loop — it may still be running whenstart()returns- Messages sent right after
start()queue in the channel and are processed once the loop begins
Shutdown Sequence in Detail
When shutdown is triggered:
Key points:
before_stopruns first, then the inbox closes and the loop drains and exits- Children are stopped after the loop exits, before
after_stop— so children finish before the parent completes before_stoponly runs on a graceful stop (stop()/ terminate signal); on a forced shutdown (cancellation) or an unexpectedly closed inbox, it is skippedafter_stopalways runs, last
Graceful Shutdown
Stopping a Single Actor
let handle = actor.start().await;
// Later...
handle.stop().await;
Stopping All Actors
let mut runtime = ActonApp::launch_async().await;
// Create and start actors...
// Shutdown everything
runtime.shutdown_all().await?;
Shutdown Timeout
Configure in ~/.config/acton/config.toml:
[timeouts]
actor_shutdown = 5000 # 5 seconds per actor
system_shutdown = 15000 # 15 seconds total
The two timeouts behave differently: if the whole system doesn't stop within system_shutdown, remaining actors are forcefully cancelled. The per-child actor_shutdown timeout only logs an error for a child that fails to stop in time — it does not force-kill it.
Error Handling in Hooks
Hooks should handle their own errors:
actor.before_start(|actor| {
Reply::pending(async move {
match load_config().await {
Ok(config) => {
// Success - continue startup
}
Err(e) => {
// Log error, but startup continues
tracing::error!("Failed to load config: {}", e);
}
}
})
});
Hooks don't fail startup
Even if a hook errors or panics, the actor still starts — provided the default catch-handler-panics feature is enabled. With default-features = false, a panicking before_start propagates out of start().await into the caller. Design your handlers to handle missing initialization gracefully.
Testing Lifecycle
Hooks are great for test assertions:
#[tokio::test]
async fn test_actor_processes_all_messages() {
let mut runtime = ActonApp::launch_async().await;
let mut actor = runtime.new_actor::<Counter>();
actor
.mutate_on::<Increment>(|actor, _| {
actor.model.count += 1;
Reply::ready()
})
.after_stop(|actor| {
// Verify final state
assert_eq!(actor.model.count, 3);
Reply::ready()
});
let handle = actor.start().await;
handle.send(Increment).await;
handle.send(Increment).await;
handle.send(Increment).await;
// Give time to process
tokio::time::sleep(Duration::from_millis(50)).await;
runtime.shutdown_all().await.unwrap();
// after_stop assertion runs during shutdown
}
Next Steps
- Supervision - Parent-child relationships and cascading shutdown
- Handler Types - How handlers execute within the lifecycle
- Configuration - Configuring timeouts and other settings