Unix Domain Sockets
Unix Domain Sockets
The dominant IPC mechanism for local daemons on Unix. Same API as TCP sockets (socket, bind, listen, accept, connect, read, write), but communication stays inside the kernel — no network stack, no TCP overhead, no port number. The address is a filesystem path.
mxr uses them. lazydap uses them. Docker, systemd, X11, dbus, postgres, mysql, ssh-agent — all use them. If two local processes need to talk and one of them is a long-lived service, the answer is almost always a Unix socket.
The API
Same as TCP, with AF_UNIX instead of AF_INET:
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {
.sun_family = AF_UNIX,
.sun_path = "/tmp/mydaemon.sock",
};
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 128);
// Server accept loop:
while (1) {
int client = accept(sock, NULL, NULL);
handle_client(client); // blocks if not async
}
Client side:
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
write(sock, "hello\n", 6);
In Rust + Tokio:
let listener = UnixListener::bind("/tmp/mydaemon.sock")?;
loop {
let (stream, _) = listener.accept().await?;
tokio::spawn(handle_client(stream));
}
let mut stream = UnixStream::connect("/tmp/mydaemon.sock").await?;
stream.write_all(b"hello\n").await?;
The familiar socket dance, but the address is a path.
Path-based vs abstract addresses
Two flavours:
- Path-based (
/tmp/mydaemon.sock) — the socket appears in the filesystem. Permissions are filesystem permissions (chmod, chown). Cross-platform (Linux, macOS, BSDs). Default. - Abstract (Linux only) — name starts with a null byte. No filesystem entry; the name lives only in the kernel's abstract namespace. No cleanup needed (vanishes when the last reference closes). Linux-specific.
For mxr / lazydap (cross-platform, want filesystem permissions), path-based is the right choice.
Permissions
Critical for security. The socket file has Unix permissions like any other file:
let listener = UnixListener::bind(path)?;
std::fs::set_permissions(path, Permissions::from_mode(0o700))?; // owner-only
0700 (rwx for owner, nothing for anyone else) is the standard for per-user daemons. 0660 (group-readable) for daemons that serve a group. World-accessible (0666) is almost never right.
The directory containing the socket also matters: /tmp is world-writable, so anyone can create symlinks. Prefer $XDG_RUNTIME_DIR (Linux) or ~/Library/Application Support (macOS) — both are owner-only.
Cleanup
Path-based sockets persist after the daemon exits. The next start fails with EADDRINUSE if the file is still there from a previous run.
Standard fix: at startup, unlink(path) before bind. Two daemons trying to start simultaneously can race; use flock-based PID files to enforce single-instance.
Abstract sockets (Linux) auto-clean — no action needed.
Why faster than TCP
Unix sockets bypass:
- The TCP/IP stack (no headers, no checksums, no congestion control)
- Network buffer copying
- Connection setup negotiation (no SYN/SYN-ACK/ACK)
- Most kernel networking overhead
Throughput on a modern machine: 5–10 GB/s for streaming, 1M+ messages/sec for small request/response. TCP loopback is ~3× slower.
For local IPC, there's no benefit to using TCP loopback over Unix sockets unless you genuinely might want to expose the service to the network later.
Datagram vs stream
Two socket types:
SOCK_STREAM— like TCP. Connection-oriented, byte stream, reliable, in-order. Default for daemons.SOCK_DGRAM— like UDP. Message-oriented (eachsendis one packet, eachrecvreturns one packet). Less common but useful for fire-and-forget messages.
Stream sockets need a framing layer because the byte stream has no inherent message boundaries. Datagram sockets get message boundaries for free.
mxr and lazydap use streams + length-prefix framing.
Passing file descriptors
Unique to Unix sockets: you can pass open file descriptors between processes. Sender includes them in the ancillary data of sendmsg(2); receiver picks them up via recvmsg(2). The receiving process gets a usable fd that aliases the sender's.
Used by:
systemdsocket activation (passes the listening socket to the daemon)- pcb sandboxing (parent opens privileged fd, hands to sandboxed child)
- Privilege separation in OpenSSH
Not common for application-level IPC; useful when you need it.
What mxr and lazydap do
Both use:
- Path-based Unix domain sockets at known per-instance paths
SOCK_STREAM(Tokio'sUnixListener/UnixStream)- Mode
0700(owner-only) - Length-prefixed JSON framing on top (see Length-Prefixed Framing)
- Per-client
tokio::spawnfor concurrency
Socket paths:
mxr: ~/.cache/mxr/mxr.sock
lazydap: ~/Library/Application Support/lazydap/instance-<hash>/lazydap.sock
(macOS uses Application Support; Linux uses $XDG_RUNTIME_DIR or ~/.cache.)
When NOT to use Unix sockets
- Cross-machine. Use TCP.
- Cross-OS to non-Unix. Windows has Unix domain sockets in modern builds, but adoption is patchy. Named pipes are the Windows-native equivalent.
- Web browser as client. Browsers can't open Unix sockets directly. Need an HTTP/WebSocket bridge.
Otherwise: yes, use Unix sockets.
See also
- Inter-Process Communication (IPC) — the broader menu
- Length-Prefixed Framing — what to do with the byte stream
- JSON-RPC — common application protocol on top
- The Local Daemon Pattern — what daemons typically listen on
- How Processes Talk to Each Other — synthesis
man 7 unix— the canonical reference