Advanced

Inter-Process Communication

Actors can communicate across process boundaries using Acton's IPC system. This enables multi-process architectures, external tooling, and polyglot systems.

When You Need IPC

  • Multi-process architectures — Separate concerns into different processes
  • External monitoring — Query actor state from monitoring tools
  • Language interop — Python, Node.js, or other languages talking to Rust actors
  • Process isolation — Crash one process without affecting others

How It Works

Acton's IPC uses Unix domain sockets for fast, local communication. Messages are length-prefixed frames whose payload is serialized as JSON (always available) or MessagePack (with the ipc-messagepack feature). Each frame declares its own format, so both kinds of client can share one listener.

The socket lives at $XDG_RUNTIME_DIR/acton/<app_name>/ipc.sock, where app_name defaults to the binary name (falling back to /tmp/acton/<app_name>/ipc.sock when XDG_RUNTIME_DIR is unset). Resolve it with IpcConfig::load().socket_path() rather than hardcoding it.

Local Only

IPC is designed for same-machine communication. For network distribution, build on top with your preferred transport.


Server Side Setup

Step 1: Mark Messages for IPC

Add the ipc option to enable serialization:

#[acton_message(ipc)]
struct GetValue;

#[acton_message(ipc)]
struct SetValue { value: i32 }

#[acton_message(ipc)]
struct ValueResponse { value: i32 }

The ipc option adds Serialize and Deserialize derives. You must still register types with the runtime.

Step 2: Register Types and Expose Actors

use acton_reactive::prelude::*;

#[acton_actor]
struct MyService {
    value: i32,
}

#[acton_main]
async fn main() {
    let mut runtime = ActonApp::launch_async().await;

    // Register IPC message types
    let registry = runtime.ipc_registry();
    registry.register::<GetValue>("GetValue");
    registry.register::<SetValue>("SetValue");
    registry.register::<ValueResponse>("ValueResponse");

    // Create and configure the service actor
    let mut service = runtime.new_actor_with_name::<MyService>("my-service".to_string());

    service
        .act_on::<GetValue>(|actor, envelope| {
            let value = actor.model.value;
            let reply_envelope = envelope.reply_envelope();

            Reply::pending(async move {
                reply_envelope.send(ValueResponse { value }).await;
            })
        })
        .mutate_on::<SetValue>(|actor, envelope| {
            actor.model.value = envelope.message().value;
            Reply::ready()
        })
        .expose_for_ipc();  // Expose using the actor's name ("my-service")

    service.start().await;

    // Start the IPC listener
    let listener = runtime.start_ipc_listener().await
        .expect("Failed to start IPC listener");

    // Keep running until Ctrl+C
    tokio::signal::ctrl_c().await.ok();

    // Graceful shutdown
    listener.shutdown_gracefully().await;
    runtime.shutdown_all().await.ok();
}

Custom IPC Names

The expose_for_ipc() method uses the actor's ERN name automatically. If you need a different IPC name, use runtime.ipc_expose("custom-name", handle) after starting the actor.


Client Side

Rust clients use IpcClient, which owns the socket, runs dedicated reader and writer tasks, and correlates responses to requests for you. No hand-rolled framing:

use acton_reactive::prelude::*;
use acton_reactive::ipc::{socket_exists, socket_is_alive};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Resolve the same socket path the server uses.
    let socket_path = IpcConfig::load().socket_path();

    if !socket_exists(&socket_path) || !socket_is_alive(&socket_path).await {
        eprintln!("Server not running at {}", socket_path.display());
        return Ok(());
    }

    let client = IpcClient::connect(&socket_path).await?;

    // new_request expects a reply; new() would be fire-and-forget.
    let envelope = IpcEnvelope::new_request(
        "my-service",  // Actor name (from expose_for_ipc or ipc_expose)
        "GetValue",    // Registered type name
        serde_json::json!({}),
    );

    let response = client.request(envelope).await?;

    if response.success {
        println!("Value: {:?}", response.payload);
    } else {
        eprintln!("{:?}: {:?}", response.error_code, response.error);
    }

    client.disconnect().await?;
    Ok(())
}

Use new_request when you want an answer

IpcEnvelope::new builds a fire-and-forget message: the server routes it to the actor and immediately replies {"status": "delivered"}, discarding whatever the actor sends back. Only IpcEnvelope::new_request (or new_request_with_timeout) sets expects_reply, which is what makes the listener wait for the actor's reply and forward it.

IpcClient also covers fire-and-forget (send), request-stream (request_stream), subscriptions (subscribe + take_push_receiver), and discovery (discover). See IPC Patterns for each. For request-stream, request_stream returns a channel that yields every frame in order and closes after the frame with is_final: true:

let envelope = IpcEnvelope::new_stream_request(
    "my-service",
    "ListValues",
    serde_json::json!({}),
);

let mut stream_rx = client.request_stream(envelope).await?;
while let Some(frame) = stream_rx.recv().await {
    println!("Frame #{}: {:?}", frame.sequence, frame.payload);
}

Client Libraries

Acton includes example client libraries for Python, Node.js, and Deno. Each speaks the same wire protocol as IpcClient.

Python

ActonIpcClient is async; ActonIpcClientSync is the blocking equivalent.

from acton_ipc import ActonIpcClient

client = ActonIpcClient("/run/user/1000/acton/my_app/ipc.sock")
await client.connect()

response = await client.request("my-service", "GetValue", {})
print(f"Value: {response.payload}")

# Push notifications
await client.subscribe(["PriceUpdate"])

Node.js

The package is acton-ipc-client, exporting ActonIpcClient.

import { ActonIpcClient } from 'acton-ipc-client';

const client = new ActonIpcClient('/run/user/1000/acton/my_app/ipc.sock');
await client.connect();

const response = await client.request('my-service', 'GetValue', {});
console.log('Value:', response.payload);

Deno

A Deno client ships alongside the Node.js one, in examples/ipc_client_libraries/deno/.

See the examples/ipc_client_libraries/ directory for complete implementations, and IPC Setup for the frame format if you are writing a client in another language.


Security Considerations

  • Unix sockets respect file permissions
  • Set appropriate permissions on the socket file
  • Validate all incoming messages
  • Consider authentication for sensitive operations

Next

Custom Supervision — Advanced failure recovery

Previous
Testing actors