All tests run on an 8-year-old MacBook Air.
When I built Ghost Engine — a resident Swift daemon that handles PDF rendering — I had to decide how Rust talks to it.
Two options: stdin/stdout IPC pipe, or a Unix domain socket.
I tried both. Here's what actually happened.
Option 1: stdin/stdout pipe
Simple. Spawn the process with Stdio::piped(), write commands to stdin, read responses from stdout.
let child = Command::new("ghost-engine-daemon")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
// Send command
writeln!(child.stdin.as_mut().unwrap(), "render:page:3")?;
// Read response
let mut response = String::new();
BufReader::new(child.stdout.as_mut().unwrap())
.read_line(&mut response)?;
Pros: Zero setup. No port conflicts. No socket file cleanup.
Cons: Strictly request-response. One command at a time per pipe pair. No multiplexing.
Option 2: Unix domain socket
More flexible. The daemon listens on a socket file, Rust connects as a client.
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect("/tmp/ghost-engine.sock")?;
stream.write_all(b"render:page:3\n")?;
let mut response = String::new();
BufReader::new(&stream).read_line(&mut response)?;
Pros: Multiple concurrent connections. Full duplex. Easier to multiplex requests.
Cons: Need to manage socket file lifecycle. Cleanup on crash requires care.
What I chose and why
For Ghost Engine: stdin/stdout pipe.
My use case is sequential rendering requests from a single Rust process. No concurrent clients, no need for multiplexing. The pipe is simpler, has zero setup overhead, and the daemon lifecycle is tied directly to the parent process — no orphan socket files if the app crashes.
If I needed multiple Tauri windows sending requests simultaneously, I'd switch to Unix socket. For now, pipe is the right fit.
The real lesson
Neither is universally better. Match the IPC mechanism to your concurrency model, not to what sounds more sophisticated.
Hiyoko PDF Vault → https://hiyokoko.gumroad.com/l/HiyokoPDFVault
X → @hiyoyok
Top comments (0)