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

HookRunsUse For
before_startInside start().await, before the actor task is spawnedLogging, sync validation (cannot send messages yet)
after_startAs the actor task begins, immediately before the message loopAsync init, send messages, notify others, start timers
before_stopWhen a graceful stop is requested (skipped on forced shutdown)Cleanup, save state, flush buffers
after_stopAfter 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_start runs inline inside start().await, in the caller's task, before anything is spawned
  • start() then spawns a Tokio task for the actor and returns the handle immediately
  • after_start runs as the actor task begins, immediately before the message loop — it may still be running when start() 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_stop runs 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_stop only runs on a graceful stop (stop() / terminate signal); on a forced shutdown (cancellation) or an unexpectedly closed inbox, it is skipped
  • after_stop always 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

Previous
Handler types