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

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 and 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.

// 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 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:

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.

GUI Client Connection Pattern

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

// 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:

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, 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
// 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →