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

A second process is Acton's unit of isolation, not parallelism. Parallelism is already free: every actor is a task on Tokio's multi-threaded work-stealing runtime, so putting work on more cores never requires another process. (Coming from Actix, this is the arbiter model inverted — an arbiter adds a thread to buy parallelism; an Acton peer process adds a bulkhead to buy isolation.) Merely long-running or blocking work doesn't need a process either: spawn_blocking inside a handler, or a dedicated actor that owns the blocking resource, solves scheduler starvation without leaving the process.

Reach for IPC when the work needs a separate failure or resource domain — or was never in your process to begin with:

  • Process isolation — Crashy FFI or native code that can corrupt a heap, contained where in-process supervision can't reach
  • Resource envelopes — Memory-hungry or CPU-heavy work the OS should schedule, nice, or OOM-kill independently
  • Independent lifecycle — Components that deploy, restart, or version on their own cadence
  • Language interop — Python, Node.js, or other languages talking to Rust actors
  • External monitoring — Query actor state from monitoring tools

The price of the bulkhead is serialization at the boundary: in-process messages move as cheap clones through channels, while every IPC message pays encoding plus syscalls, and both sides must register the types that cross.

Isolation's seam is process death

A peer that is itself an Acton runtime is fully supervised from the inside — its actors get restart policies and panic containment, and a supervised restart rebinds the actor's exposed IPC names, so remote callers keep addressing a valid handle. What no supervision tree covers is death of the peer process itself: that surfaces to the caller as an IpcError, not a supervision event, because a supervisor knows nothing about a process it did not start. Give the process an external supervisor (systemd or similar), and on the client side model the connection as state owned by an actor that can observe it dropping and react.


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

Typed Requests with ask

IpcClient::request speaks in IpcEnvelopes and serde_json::Value. When you know the types, IpcClient::actor names a remote actor and gives back a RemoteActorRef whose ask is deliberately the same call as the local one:

let count: Count = handle.ask(GetCount).await?;                  // local
let count: Count = client.actor("counter").ask(GetCount).await?; // remote

This adds no transport. It is a typed façade over the correlated request machinery that already existed, plus the judgement about what a response means.

The RemoteRequest bound

A remote request has to be able to travel, so it implements RemoteRequest rather than Request:

#[acton_message(ipc)]
struct GetCount;

#[acton_message(ipc)]
struct Count(u64);

impl Request for GetCount {
    type Response = Count;
}

impl RemoteRequest for GetCount {
    // Must equal the string the peer registered this type under.
    const MESSAGE_TYPE: &'static str = "GetCount";
}

RemoteRequest is Request plus Serialize, a DeserializeOwned reply, and a MESSAGE_TYPE constant. A message that cannot cross the boundary is a compile error against a RemoteActorRef, rather than a call that appears to work. Local-only users pay nothing: ask on ActorHandle still takes a bare Request.

The wire name is written down rather than derived from std::any::type_name, which changes when a type moves between modules and cannot describe a peer that is a different binary, a different version, or not Rust at all.

Errors

Remote failures reuse AskError, with two variants that only a boundary can produce:

VariantMeaning
PeerRejected { code, detail }Refused before dispatch: no such actor, no such registered type, busy, rate-limited, shutting down. Nothing ran, so a retry is safe. Carries the peer's own code, which is what tells a mistyped actor name from an unregistered type
TransportFailed { detail }The connection failed, so whether the request was processed is unknown

The rest carry their local meanings. A handler that returns without replying is NoReply. A deadline at either end is TimedOut. A reply that does not deserialize is UnexpectedReply, which covers a wrong-typed handler reply, an unregistered reply type, and version skew between peers, because all three mean the same actionable thing: the answer is not the answer this request declares.

It cannot hang. The client registers its correlation id before writing, so a dropped connection wakes the caller at once instead of waiting out the clock, and the deadline is stamped on the request as well as applied locally, so the peer stops waiting on its actor at the same moment.

Scope: one actor

ask has no meaning over broadcast, remotely or locally, because a broadcast has no single replier. Use subscriptions for that.

The deadlock warning applies unchanged: do not ask from inside a mutate_on handler. Crossing a process boundary does not change it, it only makes the other party harder to see.


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

Peer identity

PeerCredentials carries the kernel-reported identity of the process behind a connection, read through SubscriptionManager::peer_credentials() and peer_pid().

Use uid() and gid() to identify the operating-system user and group. Distinguishing two primitives running as the same user requires an application identity. If you use a PID for that mapping, bind it to a live child process and revoke its sessions on exit; a stale numeric PID can be reused. Credentials describe the peer when the socket connection was established, so applications must also define what an inherited or transferred socket means for identity.

Opt-in IPC policies (9.4.0+)

Start a listener with ActorRuntime::start_ipc_listener_with_policy(config, policy) to control admission and access. The policy is an Arc<dyn IpcSecurityPolicy> and must be Send, Sync, and RefUnwindSafe. The lower-level equivalent is start_listener_with_policy. Existing listener entry points retain their permissive behavior.

An IpcSecurityPolicy implements two methods:

MethodResponsibility
admit(connection) -> IpcAdmission<'_>Asynchronously map server-observed connection information to an IpcIdentity, or reject the connection
authorize(context, operation)Synchronously allow or deny an operation using the established identity and the requested data

IpcAdmission is a boxed, sendable future returning Result<IpcIdentity, IpcAccessDenied>. Admission runs independently for each accepted connection, before any frames are processed. It may consult an application actor or process registry. Pending admissions count toward limits.max_connections. The [timeouts] admission_timeout_ms setting bounds admission independently of idle reads; its default is 60,000 milliseconds, and zero disables only the admission deadline. After admission, read_timeout_ms governs clients without subscriptions and subscription_read_timeout_ms governs subscribers. Listener shutdown cancels pending admission.

The policy receives IpcConnectionInfo, whose accessors expose the connection ID and optional kernel PeerCredentials. Reject missing credentials when your policy requires them. Return IpcIdentity::new(value) with server-established identity data. Identity values must be Send, Sync, and RefUnwindSafe; keep mutable authorization state in the policy. The framework does not serialize identities or read them from a client's claimed name, source, or PID.

Typed handlers access that identity through MessageContext::ipc_context():

let primitive_name = context
    .ipc_context()
    .and_then(|caller| caller.identity::<String>());

The identity type is whichever type your admission policy stored. It remains available through request/reply, streaming, fire-and-forget, and fallible handlers. Local messages and messages forwarded through ordinary actor sends have no IPC context automatically. Explicitly use the trusted identity when validating or constructing application-level event sources.

Authorize every route

IpcOperation::Request exposes the requested actor, message type, payload, and reply/stream flags. Those fields remain untrusted input. Authorization runs before actor lookup and typed message deserialization. Other variants cover discovery, exact subscriptions, pattern subscriptions, and their unsubscribe operations. Subscription batches are authorized before mutation, so denial does not partially change subscriptions. Discovery authorization is all-or-nothing.

IpcOperation::Deliver checks each actual outgoing notification, including notifications sent directly through SubscriptionManager::forward_to_subscribers. This matters when application event topics live inside an outer IPC message type, or when a wildcard matches names registered later. Authorizing a subscription alone does not authorize all of its deliveries.

The recipient cache stores selector matches, not permission decisions. Delivery authorization runs again for each notification, even on a cache hit. Policy callbacks run outside subscription-manager locks and must be fast and nonblocking. A decision already in progress may complete while policy state changes; use connection revocation when you need to stop that session decisively.

Revoke a session

Call listener.revoke_connection(connection_id) or subscription_manager.revoke_connection(connection_id) to cancel a session, remove its subscriptions, invalidate recipient routes, and close its socket. connection_context(connection_id) returns a clone of an admitted connection's trusted context; retained clones expose is_revoked() after removal.

Revocation prevents further notification enqueueing and skips revoked IPC messages still awaiting actor dispatch. It interrupts connection reads, response waits, streams, and socket writes. It cannot undo handler work already executing or bytes already written to the socket. Reconnecting creates a new session and requires admission again.

Applications own the policy: mapping live child processes to primitives, declared publish/subscribe capabilities, startup registration order, administrative clients, and descendant processes. Acton supplies the connection identity, enforcement hooks, and revocation mechanism.

Connection limits

A server at its connection limit now writes a typed error before closing, and the client reports IpcError::ConnectionLimitReached { limit }. Previously the socket was accepted and dropped without a word, so the refusal surfaced as Broken pipe (os error 32) on the first write, which points at nothing.

The effective limit is logged at listener startup beside the socket path, so the ceiling is discoverable before it is reached. IpcListenerStats::max_connections() and connections_available() let an embedder check headroom, and IpcClient::rejection_reason() reports why a connection was refused, or None for one accepted normally.

IpcError is now #[non_exhaustive], so a match listing every variant needs a wildcard arm. Nothing changes on the wire: a refusal travels as an ordinary error response carrying an error_code string, so a client built against 8.x still parses the frame. CONNECTION_LIMIT_REACHED_CODE and CONNECTION_REJECTED_CORRELATION_ID are the wire constants a non-Rust client needs to recognise one.


Next

Custom Supervision — Advanced failure recovery

Previous
Testing actors