# How OpenLogi's Multi-Process Architecture Manages GUI, Agent, and Overlay Communication Using IPC

> Explore OpenLogi's multi-process architecture and discover how it uses local-socket IPC with tarpc and bincode for seamless GUI, agent, and overlay communication via versioned RPC calls.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: architecture
- Published: 2026-09-02

---

**OpenLogi uses a local-socket IPC layer built on tarpc and bincode to coordinate three independent processes—GUI, agent, and overlay—through versioned RPC calls with an append-only wire format.**

OpenLogi is a native, local-first replacement for Logitech Options⁺ that splits functionality across three specialized processes. This multi-process architecture isolates input handling from UI rendering, but requires reliable inter-process communication (IPC) to function as a cohesive system. The implementation uses tarpc for RPC, bincode for serialization, and interprocess for local socket transport, all designed around strict binary compatibility guarantees.

## The Three Processes and Their Roles

| Process | Crate Path | Responsibility |
|---------|-----------|----------------|
| **GUI** | `crates/openlogi-desktop` | Configuration UI, Actions Ring interaction, user-facing controls |
| **Agent** | `crates/openlogi-agent` | HID++ device communication, input hooks, DPI/SmartShift logic |
| **Overlay** | `crates/openlogi-overlay` | Lightweight cursor-centered Actions Ring when GUI is hidden |

The agent owns the hardware. The GUI and overlay are stateless clients that query and control the agent through IPC. This separation lets the overlay remain minimal—typically under 10 MB—while the full GUI can load heavier frameworks.

## IPC Transport Stack

OpenLogi's IPC stack combines three crates:

- **`interprocess`** – Provides **local sockets** (Unix sockets on macOS/Linux, named pipes on Windows)
- **`tarpc`** – Builds the **RPC framework** atop the socket
- **`bincode`** – Handles **zero-copy binary serialization**

The transport is defined across [`crates/openlogi-ipc/src/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/src/ipc.rs) and [`crates/openlogi-agent/src/server.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent/src/server.rs). The socket path is platform-specific but consistent within a session—`/tmp/openlogi.sock` on Unix systems.

## Protocol Version Handshake

Every connection begins with version negotiation. The client calls `protocol_version` (method index 0) and the agent responds with its `PROTOCOL_VERSION` constant. Mismatched versions trigger immediate disconnection, preventing serialization errors.

```rust
// From crates/openlogi-ipc/src/ipc.rs
#[tarpc::service]
pub trait OpenLogiIpc {
    async fn protocol_version() -> u64;
    // Additional methods appended only at the end
    async fn get_device_info() -> DeviceInfo;
    async fn set_dpi(dpi: u16) -> Result<(), Error>;
    async fn show_actions_ring(config: RingConfig) -> Result<(), Error>;
}

```

This handshake is mandatory. The agent in [`crates/openlogi-agent/src/server.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent/src/server.rs) enforces it before processing any stateful requests.

## Agent IPC Server Implementation

The agent spawns the IPC server during startup. In [`crates/openlogi-agent/src/server.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent/src/server.rs):

```rust
use interprocess::local_socket::LocalSocketListener;
use tarpc::serde_transport::bincode::Bincode;
use openlogi_ipc::OpenLogiIpcServer;

pub async fn run_ipc_server() -> anyhow::Result<()> {
    // Single-instance enforcement: fails if socket already bound
    let listener = LocalSocketListener::bind(SOCKET_PATH)?;
    let transport = tarpc::serde_transport::new_bincode(listener, Bincode::default());
    
    let server = OpenLogiIpcServer::new(AgentIpcImpl::new());
    server.serve(transport).await?;
    Ok(())
}

```

The server is **single-instance by design**. A running release agent blocks debug builds from taking the socket, protecting users from accidental downgrades or development interference. This behavior is documented in [`crates/openlogi-ipc/AGENTS.md`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/AGENTS.md).

## GUI Client Connection Pattern

The GUI process in `crates/openlogi-desktop` connects as a tarpc client:

```rust
// From crates/openlogi-desktop/src/main.rs (connection flow)
use interprocess::local_socket::LocalSocketStream;
use tarpc::serde_transport::bincode::Bincode;
use openlogi_ipc::OpenLogiIpcClient;

pub async fn connect_to_agent() -> anyhow::Result<OpenLogiIpcClient> {
    let stream = LocalSocketStream::connect(SOCKET_PATH)?;
    let transport = tarpc::serde_transport::new_bincode(stream, Bincode::default());
    let client = OpenLogiIpcClient::new(transport);
    
    // Mandatory version check
    let version = client.protocol_version().await?;
    assert_eq!(version, PROTOCOL_VERSION, "Agent version mismatch");
    
    Ok(client)
}

```

The GUI maintains a persistent connection for the application lifetime, subscribing to device events and issuing configuration changes.

## Overlay Lightweight Client

The overlay uses a **connect-request-disconnect** pattern for minimal resource usage. From [`crates/openlogi-agent/src/overlay.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-agent/src/overlay.rs):

```rust
pub async fn show_ring() -> anyhow::Result<()> {
    let stream = LocalSocketStream::connect(SOCKET_PATH)?;
    let transport = tarpc::serde_transport::new_bincode(stream, Bincode::default());
    let client = OpenLogiIpcClient::new(transport);
    
    // Version handshake implicit in first call
    client.show_actions_ring(RingConfig::default()).await?;
    
    // Connection drops here, overlay remains stateless
    Ok(())
}

```

The overlay holds no persistent socket. If the agent restarts, the overlay simply reconnects on the next user trigger. This design tolerates agent crashes without UI corruption.

## Append-Only Binary Compatibility

The wire format in OpenLogi IPC is **append-only and positional**. This rule, codified in [`crates/openlogi-ipc/AGENTS.md`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/AGENTS.md), ensures:

- **Method indices never change**—new RPC methods append to the trait
- **Enum variants never reordered**—new variants append to the end
- **No method or field removal**—deprecated items remain with `#[deprecated]` attributes

```rust
// Valid evolution: append at the end
#[tarpc::service]
pub trait OpenLogiIpc {
    async fn protocol_version() -> u64;        // Index 0 (unchanged since v1)
    async fn get_device_info() -> DeviceInfo;   // Index 1 (unchanged since v1)
    async fn set_dpi(dpi: u16) -> Result<(), Error>; // Index 2 (added v2)
    async fn set_smart_shift(enabled: bool) -> Result<(), Error>; // Index 3 (added v3)
    // Future methods continue at index 4, 5, ...
}

```

This strategy avoids protocol buffers or JSON overhead while maintaining forward and backward compatibility without explicit versioning fields in every message.

## Fault Tolerance and Recovery

The architecture handles process failures through socket-level detection:

- **Agent crash** – GUI and overlay detect broken connections on next I/O
- **Agent restart** – Single-instance check prevents duplicate agents; clients reconnect automatically
- **Overlay statelessness** – No recovery needed; each Actions Ring trigger is an independent transaction
- **GUI statefulness** – Reconnection triggers full state resync from agent

The `interprocess` crate surfaces OS-level socket errors (`ECONNREFUSED`, `EPIPE`) that tarpc converts to transport errors, which the clients handle with exponential backoff retry.

## Performance Characteristics

| Metric | Implementation Detail |
|--------|----------------------|
| Serialization overhead | ~1-2 microseconds for typical device structs (bincode zero-copy) |
| Connection latency | Unix socket connect: ~50-100 microseconds |
| RPC throughput | Limited by single-threaded tarpc server; adequate for HID event rates |
| Memory per connection | ~8 KB buffer per socket, negligible for 2-3 concurrent clients |

The bottleneck is never IPC—HID devices report at 100-1000 Hz, while the IPC layer handles >100,000 RPCs/second on modern hardware.

## Summary

- **Three processes** (GUI, agent, overlay) communicate through **local sockets** using **tarpc + bincode**
- **Version handshake** on every connection prevents incompatible wire formats
- **Append-only protocol** guarantees binary compatibility without schema registries
- **Single-instance agent** protects production users from debug interference
- **Stateless overlay** tolerates agent restarts without state corruption

## Frequently Asked Questions

### What happens if the GUI and agent versions mismatch?

The connection is rejected during the `protocol_version` handshake. Both sides log the mismatch, and the GUI displays an upgrade prompt. The agent never processes requests from incompatible clients, preventing serialization panics.

### Why use bincode instead of JSON or Protocol Buffers?

Bincode provides **zero-copy deserialization** for Rust structs without code generation or schema files. The append-only design achieves compatibility without the complexity of protobuf's `optional`/`reserved` fields or JSON's parsing overhead.

### Can multiple GUI instances connect simultaneously?

No—the single-instance agent accepts multiple **connections** but treats them as equivalent clients. State modifications from one GUI propagate to others through their independent RPC calls. There is no publish-subscribe; clients poll or trigger updates explicitly.

### How does the overlay stay lightweight?

The overlay process excludes the full GUI framework (likely Tauri/Electron). It renders only the Actions Ring through a minimal windowing layer, connects to the agent only when triggered, and holds no persistent state. This keeps memory usage under 10 MB versus 100+ MB for the full GUI.