How OpenLogi Uses tarpc and bincode for Cross-Platform IPC Over Local Sockets
OpenLogi implements a unified IPC protocol where the GUI client and background agent communicate over Unix-domain sockets on macOS/Linux and named pipes on Windows, using tarpc for RPC framing and bincode for binary serialization.
The OpenLogi source code demonstrates a clean abstraction for local inter-process communication (IPC) that works identically across operating systems. The tarpc/bincode IPC protocol powers all communication between the desktop interface and the agent process, with platform-specific socket types hidden behind a common transport layer.
Architecture Overview: tarpc + bincode IPC in OpenLogi
The protocol stack consists of three layers working together:
- tarpc — Provides the RPC framework, request/response matching, and code generation via
#[tarpc::service] - bincode — Handles compact binary serialization of Rust data structures
- interprocess — Abstracts the underlying socket type (Unix domain sockets vs. Windows named pipes)
This design lets developers write RPC code once and run it on any supported platform without modification.
Service Contract Definition
The RPC interface is defined in crates/openlogi-ipc/src/ipc.rs using the tarpc service macro.
Generated Client and Server Glue
The #[tarpc::service] annotation on the Agent trait produces:
AgentClient— Used by GUI and CLI tools to call agent methodsservefunction — Connects the trait implementation to the transport in the agent binary
All methods follow strict request/response semantics. Even server-to-client pushes like observe are modeled as long-lived requests that the client initiates and keeps open.
Version Negotiation
The contract includes a protocol_version method that returns a wire-format version number. Both client and server compare this against crate::PROTOCOL_VERSION (currently 29) to verify compatibility before proceeding with other calls.
Cross-Platform Transport Layer
The transport implementation in crates/openlogi-ipc/src/transport.rs resolves platform differences at bind and connect time, then presents identical Stream and Listener types to the rest of the code.
Endpoint Resolution
The endpoint_name() function returns an interprocess::local_socket::Name:
| Platform | Result |
|---|---|
| Unix (macOS/Linux) | Filesystem path: $HOME/.config/openlogi/agent.sock via openlogi_core::paths::agent_socket_path() |
| Windows | Named pipe: \\.\pipe\openlogi-agent.sock |
Connection Establishment
Server side (bind()) — Creates a Listener. On Unix, it also creates parent directories for the socket file.
Client side (connect()) — Creates a Stream connected to the resolved endpoint. The same Stream::connect call works for both socket types due to the interprocess abstraction.
Protocol Wrapping
The wrap(stream) function layers the RPC protocol over any raw stream:
pub fn wrap<S>(stream: S) -> Transport
where
S: AsyncRead + AsyncWrite + Send + 'static,
{
tarpc::serde_transport::Transport::from((
stream,
Bincode::default(), // bincode serialization
LengthDelimitedCodec::new(), // length-delimited framing
))
}
This identical wrapping runs on both platforms. The only variance is the underlying Stream implementation provided by interprocess.
Client-Side Connection Flow
The client implementation in crates/openlogi-ipc/src/client.rs establishes connections with a mandatory version handshake:
use openlogi_ipc::client::{self, ConnectError};
use openlogi_ipc::transport;
async fn start_agent_client() -> Result<(), ConnectError> {
// Connect to the platform-specific socket/pipe
let stream = transport::connect().await?;
// Wrap with tarpc + bincode framing
let transport = transport::wrap(stream);
// Spawn the tarpc client
let client = openlogi_ipc::AgentClient::new(
client::Config::default(),
transport,
).spawn();
// Version handshake before any other calls
let version = client
.protocol_version(openlogi_ipc::context::current())
.await?;
println!("Agent protocol version: {}", version);
Ok(())
}
The handshake ensures that GUI and agent binaries are compatible before exposing functionality to users.
Server-Side Listening and Serving
The agent binary in crates/openlogi-agent/src/server.rs binds the transport and serves requests:
use openlogi_ipc::transport;
use openlogi_ipc::Agent; // concrete trait implementation
async fn run_ipc_server() -> std::io::Result<()> {
// Platform-specific listener (Unix socket or Windows pipe)
let listener = transport::bind()?;
// Accept and serve connections
let server = tarpc::server::BaseChannel::with_config(
listener,
tarpc::server::Config::default(),
);
server.serve(Agent::new()).await?;
Ok(())
}
Each accepted connection passes through transport::wrap inside the serving machinery, applying identical bincode framing before dispatching to the Agent implementation.
Wire Stability Guarantees
The protocol maintains compatibility through several mechanisms:
- Length-delimited framing —
tokio_util::codec::LengthDelimitedCodecprepends message lengths, so receivers know exact boundaries regardless of OS buffering behavior - Deterministic bincode encoding — Field order and enum variant indices are serialized predictably
- Immutable method ordering — tarpc encodes method calls as enum variant indices; changing order breaks the wire format
- Explicit versioning —
PROTOCOL_VERSIONbumps on any breaking change
These guarantees allow CI pipelines to validate the "golden wire format" and catch accidental protocol changes.
Summary
- The tarpc/bincode IPC protocol in OpenLogi abstracts Unix sockets and Windows named pipes through the
interprocesscrate - Service contracts in
ipc.rsgenerate type-safe client and server code transport.rshandles platform-specific bind/connect logic, then wraps streams with identical framing- Mandatory version handshakes prevent incompatible client-agent pairs from communicating
- Length-delimited framing and stable bincode encoding ensure reliable cross-platform operation
Frequently Asked Questions
How does OpenLogi handle IPC differently on Windows versus Unix?
Both platforms use the same tarpc and bincode layers. The only difference is the underlying socket type: Unix-domain sockets on macOS/Linux and named pipes on Windows. The interprocess crate and the custom transport.rs module hide this difference, exposing identical Stream and Listener types to the RPC code.
Why does OpenLogi use bincode instead of JSON or Protobuf?
Bincode provides compact, fast binary serialization with zero-copy deserialization where possible. Since both client and agent are Rust binaries built from the same source tree, bincode's Rust-centric design avoids schema files and external tooling while maintaining deterministic wire formats suitable for version compatibility checks.
What happens if the GUI and agent have different protocol versions?
The client calls protocol_version immediately after connecting and compares the result against its own PROTOCOL_VERSION constant (currently 29). If they differ, the client can refuse to proceed, prompt for upgrade, or implement backward-compatible fallback logic. This prevents undefined behavior from mismatched method expectations.
How does the length-delimited codec prevent message corruption?
LengthDelimitedCodec prefixes each serialized message with its byte length. The receiver reads this length first, then reads exactly that many bytes before attempting deserialization. This eliminates framing errors caused by partial reads, buffer coalescing, or platform-specific pipe behavior, ensuring bincode always receives complete, valid messages.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →