Learn Zig Series (#68) - Unix Domain Sockets
Learn Zig Series (#68) - Unix Domain Sockets

What will I learn
- How Unix domain sockets provide local-only IPC that is faster than TCP loopback;
- The difference between stream sockets (SOCK_STREAM) and datagram sockets (SOCK_DGRAM) on the Unix domain;
- How to create, bind, listen, accept, and connect using Zig's std.posix APIs;
- How to pass open file descriptors between unrelated processes using sendmsg/recvmsg;
- The difference between filesystem socket paths and Linux abstract socket names;
- How to authenticate peer processes using SO_PEERCRED;
- How Unix domain sockets compare to pipes, TCP, and shared memory for IPC;
- How to build a practical command daemon that accepts structured commands over a Unix socket.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets (this post)
Learn Zig Series (#68) - Unix Domain Sockets
Solutions to Episode 67 Exercises
Exercise 1: Watchdog timer with SIGALRM
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const c = @cImport({
@cInclude("unistd.h");
@cInclude("signal.h");
});
var child_pid: posix.pid_t = 0;
var alarm_fired: bool = false;
fn alarmHandler(sig: c_int) callconv(.c) void {
_ = sig;
@atomicStore(bool, &alarm_fired, true, .release);
// kill the slow child
const pid = @atomicLoad(posix.pid_t, &child_pid, .acquire);
if (pid > 0) {
_ = linux.kill(pid, linux.SIG.TERM);
}
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var sa: linux.Sigaction = .{
.handler = .{ .handler = alarmHandler },
.mask = linux.empty_sigset,
.flags = 0,
};
_ = linux.sigaction(linux.SIG.ALRM, &sa, null);
// Test 1: fast child (500ms) -- should finish before 2s alarm
try stdout.print("=== Test 1: fast child ===\n", .{});
const pid1 = try posix.fork();
if (pid1 == 0) {
std.time.sleep(500 * std.time.ns_per_ms);
std.process.exit(0);
}
@atomicStore(posix.pid_t, &child_pid, pid1, .release);
@atomicStore(bool, &alarm_fired, false, .release);
_ = c.alarm(2);
_ = std.posix.waitpid(pid1, 0);
if (!@atomicLoad(bool, &alarm_fired, .acquire)) {
_ = c.alarm(0); // cancel alarm
try stdout.print("Child {d} finished in time. Success!\n", .{pid1});
}
// Test 2: slow child (5s) -- alarm fires after 2s
try stdout.print("\n=== Test 2: slow child ===\n", .{});
const pid2 = try posix.fork();
if (pid2 == 0) {
std.time.sleep(5000 * std.time.ns_per_ms);
std.process.exit(0);
}
@atomicStore(posix.pid_t, &child_pid, pid2, .release);
@atomicStore(bool, &alarm_fired, false, .release);
_ = c.alarm(2);
_ = std.posix.waitpid(pid2, 0);
if (@atomicLoad(bool, &alarm_fired, .acquire)) {
try stdout.print("Child {d} timed out! Killed by watchdog.\n", .{pid2});
} else {
_ = c.alarm(0);
try stdout.print("Child {d} finished in time.\n", .{pid2});
}
}
The alarm() syscall sets a kernel timer that delivers SIGALRM after the specified number of seconds. Calling alarm(0) cancels a pending alarm. The handler stores the fired flag atomically and sends SIGTERM to the child. Because waitpid is interrupted by the signal (we didn't set SA_RESTART), it returns immediately after the handler runs.
Exercise 2: Signal-driven log rotator
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
var sig_pipe_fd: posix.fd_t = -1;
fn rotateSignalHandler(sig: c_int) callconv(.c) void {
_ = sig;
const byte = [_]u8{1};
_ = posix.write(sig_pipe_fd, &byte) catch {};
}
fn rotateLogFile() !void {
const stderr = std.io.getStdErr().writer();
// move .1 to .2 if it exists
std.fs.cwd().rename("/tmp/zig_log_test.log.1", "/tmp/zig_log_test.log.2") catch {};
// move current to .1
std.fs.cwd().rename("/tmp/zig_log_test.log", "/tmp/zig_log_test.log.1") catch |err| {
try stderr.print("Rename failed: {}\n", .{err});
};
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// self-pipe for SIGUSR1
const pipe_fds = try posix.pipe();
sig_pipe_fd = pipe_fds[1];
var flags = linux.fcntl(pipe_fds[1], linux.F.GETFL, @as(linux.fd_t, 0));
_ = linux.fcntl(pipe_fds[1], linux.F.SETFL, flags | @as(u32, @bitCast(linux.O{ .NONBLOCK = true })));
var sa: linux.Sigaction = .{
.handler = .{ .handler = rotateSignalHandler },
.mask = linux.empty_sigset,
.flags = linux.SA.RESTART,
};
_ = linux.sigaction(linux.SIG.USR1, &sa, null);
try stdout.print("Logger PID: {d}. Send SIGUSR1 to rotate.\n", .{linux.getpid()});
var log_file = try std.fs.cwd().createFile("/tmp/zig_log_test.log", .{});
var line_num: u64 = 0;
while (line_num < 200) {
var pollfds = [_]linux.pollfd{.{
.fd = pipe_fds[0],
.events = linux.POLL.IN,
.revents = 0,
}};
const ready = linux.poll(&pollfds, 1, 100);
if (@as(isize, @bitCast(@as(usize, ready))) > 0) {
if (pollfds[0].revents & linux.POLL.IN != 0) {
var drain: [16]u8 = undefined;
_ = posix.read(pipe_fds[0], &drain) catch {};
// close current, rotate, open fresh
log_file.close();
try rotateLogFile();
log_file = try std.fs.cwd().createFile("/tmp/zig_log_test.log", .{});
try stdout.print("[rotated] new log file at line {d}\n", .{line_num});
}
}
var buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "line {d}: log entry here\n", .{line_num}) catch continue;
_ = log_file.write(msg) catch {};
line_num += 1;
}
log_file.close();
posix.close(pipe_fds[0]);
posix.close(pipe_fds[1]);
try stdout.print("Done. Wrote {d} lines total.\n", .{line_num});
}
The signal handler does NOT touch any file operations -- it only writes a byte to the self-pipe. All the actual rotation (rename, close, create) happens in the main loop where it's safe to use allocators and file I/O. This is the self-pipe pattern from episode 67 applied to a real use case.
Exercise 3: Process pool with signal lifecycle
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const MAX_WORKERS = 8;
var worker_pids: [MAX_WORKERS]posix.pid_t = [_]posix.pid_t{0} ** MAX_WORKERS;
var num_workers: u32 = 0;
var sig_pipe_fd: posix.fd_t = -1;
fn poolSignalHandler(sig: c_int) callconv(.c) void {
const byte = [_]u8{@intCast(@as(u32, @bitCast(sig)))};
_ = posix.write(sig_pipe_fd, &byte) catch {};
}
fn spawnWorker(stdout: anytype) !void {
if (num_workers >= MAX_WORKERS) {
try stdout.print("Max workers ({d}) reached\n", .{MAX_WORKERS});
return;
}
const pid = try posix.fork();
if (pid == 0) {
// worker loop
while (true) {
std.time.sleep(200 * std.time.ns_per_ms);
}
}
worker_pids[num_workers] = pid;
num_workers += 1;
try stdout.print("[pool] spawned worker {d} (total: {d})\n", .{ pid, num_workers });
}
fn removeLastWorker(stdout: anytype) !void {
if (num_workers == 0) return;
num_workers -= 1;
const pid = worker_pids[num_workers];
_ = linux.kill(pid, linux.SIG.TERM);
_ = std.posix.waitpid(pid, 0);
worker_pids[num_workers] = 0;
try stdout.print("[pool] removed worker {d} (total: {d})\n", .{ pid, num_workers });
}
fn reapExited(stdout: anytype) !void {
while (true) {
const result = linux.waitpid(-1, null, linux.W.NOHANG);
const pid = @as(i32, @bitCast(@as(u32, @truncate(result))));
if (pid <= 0) break;
// find and remove from array
for (0..num_workers) |i| {
if (worker_pids[i] == pid) {
try stdout.print("[pool] WARNING: worker {d} exited unexpectedly\n", .{pid});
// shift remaining
var j = i;
while (j + 1 < num_workers) : (j += 1) {
worker_pids[j] = worker_pids[j + 1];
}
num_workers -= 1;
worker_pids[num_workers] = 0;
break;
}
}
}
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const pipe_fds = try posix.pipe();
sig_pipe_fd = pipe_fds[1];
var flags = linux.fcntl(pipe_fds[1], linux.F.GETFL, @as(linux.fd_t, 0));
_ = linux.fcntl(pipe_fds[1], linux.F.SETFL, flags | @as(u32, @bitCast(linux.O{ .NONBLOCK = true })));
var sa: linux.Sigaction = .{
.handler = .{ .handler = poolSignalHandler },
.mask = linux.empty_sigset,
.flags = linux.SA.RESTART | linux.SA.NOCLDSTOP,
};
_ = linux.sigaction(linux.SIG.USR1, &sa, null);
_ = linux.sigaction(linux.SIG.USR2, &sa, null);
_ = linux.sigaction(linux.SIG.TERM, &sa, null);
_ = linux.sigaction(linux.SIG.CHLD, &sa, null);
try stdout.print("Pool manager PID: {d}\n", .{linux.getpid()});
try stdout.print(" SIGUSR1 = add worker, SIGUSR2 = remove, SIGTERM = shutdown\n", .{});
// spawn initial 4 workers
for (0..4) |_| try spawnWorker(stdout);
var running = true;
while (running) {
var pollfds = [_]linux.pollfd{.{
.fd = pipe_fds[0],
.events = linux.POLL.IN,
.revents = 0,
}};
_ = linux.poll(&pollfds, 1, 1000);
if (pollfds[0].revents & linux.POLL.IN != 0) {
var buf: [32]u8 = undefined;
const n = posix.read(pipe_fds[0], &buf) catch 0;
for (buf[0..n]) |sig| {
switch (sig) {
@intCast(linux.SIG.USR1) => try spawnWorker(stdout),
@intCast(linux.SIG.USR2) => try removeLastWorker(stdout),
@intCast(linux.SIG.CHLD) => try reapExited(stdout),
@intCast(linux.SIG.TERM) => {
try stdout.print("[pool] shutdown requested\n", .{});
running = false;
},
else => {},
}
}
}
}
// graceful shutdown: SIGTERM all workers, wait for each
try stdout.print("Shutting down {d} workers...\n", .{num_workers});
for (worker_pids[0..num_workers]) |pid| {
if (pid > 0) _ = linux.kill(pid, linux.SIG.TERM);
}
while (num_workers > 0) {
const result = linux.waitpid(-1, null, 0);
const pid = @as(i32, @bitCast(@as(u32, @truncate(result))));
if (pid > 0) {
for (0..num_workers) |i| {
if (worker_pids[i] == pid) {
var j = i;
while (j + 1 < num_workers) : (j += 1) worker_pids[j] = worker_pids[j + 1];
num_workers -= 1;
break;
}
}
}
}
try stdout.print("All workers stopped. Pool manager exiting.\n", .{});
posix.close(pipe_fds[0]);
posix.close(pipe_fds[1]);
}
All four signals (USR1, USR2, TERM, CHLD) funnel through the same self-pipe. The main loop reads the signal byte and dispatches to the right action. SIGCHLD triggers reapExited which calls waitpid with WNOHANG in a loop -- same pattern from episode 67's SIGCHLD section. The graceful shutdown sends SIGTERM to every worker and blocks on waitpid until all are gone.
Last episode we covered signal handling -- the asynchronous notification system that lets the kernel interrupt your process at any point. We registered handlers with sigaction, used the self-pipe trick to convert signals into pollable I/O events, blocked signals during critical sections, and built graceful shutdown patterns. All critical stuff for long-running services.
But here's the thing about all the IPC mechanisms we've covered so far: pipes are one-directional and anonymous (only related processes can use them), shared memory requires careful synchronization and has no built-in message boundaries, and TCP sockets work but go through the entire network stack even when both endpoints are on the same machine. What if you want bidirectional communication between unrelated processes on the same host, with message framing, without the overhead of TCP?
That's exactly what Unix domain sockets are for. They look and feel like network sockets (same socket(), bind(), listen(), accept(), connect() API) but they skip the entire TCP/IP stack. No routing, no checksums, no IP fragmentation, no network buffers. The kernel just copies bytes between socket buffers -- or, on some implementations, directly between address spaces. On benchmarks this typically runs 2-3x faster than TCP loopback for small messages and even more for large transfers.
Every major Unix service uses them. Docker talks to its daemon through /var/run/docker.sock. X11 applications communicate with the display server via /tmp/.X11-unix/X0. PostgreSQL prefers Unix sockets for local connections. Systemd's journal, D-Bus, Wayland -- all Unix domain sockets. They're the standard for local IPC in the Unix world, and once you understand them you'll see them everywhere ;-)
Here we go!
Stream vs datagram: two flavors of Unix socket
Just like with TCP and UDP in the network world (as we covered in episode 21), Unix domain sockets come in two flavors:
SOCK_STREAM -- connection-oriented, reliable, ordered byte stream. Like TCP but without the network overhead. You connect once, then send and receive freely. The data arrives in order and complete. This is what you want for most IPC scenarios.
SOCK_DGRAM -- connectionless, message-oriented. Like UDP but on the Unix domain -- so actually reliable (no packets to lose since there's no network). Each sendto delivers exactly one message that the receiver gets as a complete unit via recvfrom. No partial reads, no message merging. Useful when you want discrete messages without the overhead of connection setup.
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// create a stream socket (like TCP -- connection oriented)
const stream_fd = try posix.socket(
posix.AF.UNIX,
posix.SOCK.STREAM,
0,
);
defer posix.close(stream_fd);
// create a datagram socket (like UDP -- message oriented)
const dgram_fd = try posix.socket(
posix.AF.UNIX,
posix.SOCK.DGRAM,
0,
);
defer posix.close(dgram_fd);
try stdout.print("Stream socket fd: {d}\n", .{stream_fd});
try stdout.print("Dgram socket fd: {d}\n", .{dgram_fd});
try stdout.print("AF.UNIX = {d}\n", .{posix.AF.UNIX});
// both are just file descriptors -- same as everything else in Unix
// you can poll them, select on them, pass them to children via fork
}
The socket call returns a file descriptor. Same as a file, same as a pipe -- it's all just fds in Unix. The AF.UNIX (also called AF.LOCAL) address family tells the kernel this is a local socket, not a network socket. The third argument (protocol) is 0 because there's only one protocol for each socket type in the Unix domain.
For this episode we'll focus primarily on SOCK_STREAM since that's what you'll use 90% of the time. Datagram sockets on the Unix domain are less common because stream sockets are already local (so no network reliability concern) and connection-oriented communication is usually what you actually want.
Creating, binding, listening, and connecting
The socket lifecycle for Unix domain sockets is identical to TCP sockets in terms of API calls, but the address structure is different. Instead of an IP address and port number, you provide a filesystem path:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const SOCKET_PATH = "/tmp/zig_demo.sock";
fn runServer() !void {
const stdout = std.io.getStdOut().writer();
// remove stale socket file from previous runs
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
// 1. create socket
const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(server_fd);
// 2. bind to a filesystem path
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
// 3. listen for connections
try posix.listen(server_fd, 5);
try stdout.print("[server] listening on {s}\n", .{SOCKET_PATH});
// 4. accept a client
var client_addr: posix.sockaddr = undefined;
var client_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client_fd = try posix.accept(server_fd, &client_addr, &client_len);
defer posix.close(client_fd);
try stdout.print("[server] client connected\n", .{});
// 5. read data from client
var buf: [256]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[server] received: {s}\n", .{buf[0..n]});
// 6. send response
_ = try posix.write(client_fd, "hello from server!");
}
fn runClient() !void {
const stdout = std.io.getStdOut().writer();
// give server a moment to start listening
std.time.sleep(100 * std.time.ns_per_ms);
const client_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(client_fd);
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.connect(client_fd, &addr.any, addr.getOsSockLen());
try stdout.print("[client] connected to {s}\n", .{SOCKET_PATH});
_ = try posix.write(client_fd, "hello from client!");
var buf: [256]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[client] server says: {s}\n", .{buf[0..n]});
}
pub fn main() !void {
const pid = try posix.fork();
if (pid == 0) {
// child = client
runClient() catch |err| {
std.debug.print("client error: {}\n", .{err});
};
std.process.exit(0);
}
// parent = server
runServer() catch |err| {
std.debug.print("server error: {}\n", .{err});
};
_ = std.posix.waitpid(pid, 0);
// clean up socket file
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
}
A few things to notice here:
std.net.Address.initUnixdoes the work of filling in thesockaddr_unstructure with the filesystem path. This is Zig's standard library doing the right thing -- you don't have to manually zero-fill asockaddr_unand copy in the path bytes.The socket file must be deleted before binding. If a previous run crashed and left
/tmp/zig_demo.sockbehind,bindwill fail withAddressInUse. This is why every Unix socket server starts withdeleteFile(orunlinkin C). The socket file is just a name in the filesystem that the kernel uses for rendezvous -- it's NOT a regular file and has zero bytes of content.The
accept/connectpattern is identical to TCP (episode 21). Once connected,readandwritework exactly the same way. If you've already built a TCP server in Zig, switching to Unix sockets is literally changing the address family and address type. The rest of the code doesn't change.We use fork to run client and server in the same program. This is convenient for demos, but in real life the server and client are typically seperate programs. The whole point of Unix sockets is that they work between unrelated processes -- any process that can access the socket file can connect.
Abstract sockets: no filesystem, no cleanup
One annoying thing about filesystem sockets is you have to manage the file lifecycle. Delete before bind, clean up on exit, deal with stale files after crashes. Linux has a solution: abstract sockets.
An abstract socket's name starts with a null byte (\0). It lives in an abstract namespace managed by the kernel, not in the filesystem. No file is created, no cleanup is needed. When all file descriptors referencing the socket are closed, the name is automatically freed.
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
fn runAbstractServer() !void {
const stdout = std.io.getStdOut().writer();
const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(server_fd);
// abstract socket: path starts with \0
// initUnix handles this -- just pass a name starting with \0
const addr = try std.net.Address.initUnix("\x00zig-abstract-demo");
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
try posix.listen(server_fd, 5);
try stdout.print("[server] listening on abstract socket 'zig-abstract-demo'\n", .{});
var client_addr: posix.sockaddr = undefined;
var client_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client_fd = try posix.accept(server_fd, &client_addr, &client_len);
defer posix.close(client_fd);
var buf: [256]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[server] got: {s}\n", .{buf[0..n]});
_ = try posix.write(client_fd, "ack from abstract server");
}
fn runAbstractClient() !void {
const stdout = std.io.getStdOut().writer();
std.time.sleep(100 * std.time.ns_per_ms);
const client_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(client_fd);
const addr = try std.net.Address.initUnix("\x00zig-abstract-demo");
try posix.connect(client_fd, &addr.any, addr.getOsSockLen());
_ = try posix.write(client_fd, "abstract socket test");
var buf: [256]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[client] server says: {s}\n", .{buf[0..n]});
}
pub fn main() !void {
const pid = try posix.fork();
if (pid == 0) {
runAbstractClient() catch {};
std.process.exit(0);
}
runAbstractServer() catch |err| {
std.debug.print("server error: {}\n", .{err});
};
_ = std.posix.waitpid(pid, 0);
// no cleanup needed -- no filesystem socket to delete!
}
The tradeoff is clear: abstract sockets are cleaner (no file to manage) but less portable (Linux-only -- macOS and BSDs don't support them) and lack filesystem permissions (any process on the system can connect). Filesystem sockets let you use standard Unix file permissions (chmod, file ownership) to control who can connect. For services that need access control, filesystem sockets with proper permissions are the better choice.
You can see which abstract sockets are active on your system with ss -xlp or netstat -xlp -- they show up with an @ prefix in the address field.
Peer credentials: who connected to us?
When a client connects to your Unix socket, you might want to know WHO they are. Not just "some process on this machine" but specifically which user, which process ID, which group. The SO_PEERCRED socket option gives you this:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const c = @cImport({
@cInclude("sys/socket.h");
});
const SOCKET_PATH = "/tmp/zig_cred.sock";
fn runCredServer() !void {
const stdout = std.io.getStdOut().writer();
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(server_fd);
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
try posix.listen(server_fd, 5);
try stdout.print("[server] waiting for connection...\n", .{});
var client_addr: posix.sockaddr = undefined;
var client_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client_fd = try posix.accept(server_fd, &client_addr, &client_len);
defer posix.close(client_fd);
// query peer credentials
const UcredT = extern struct {
pid: i32,
uid: u32,
gid: u32,
};
var cred: UcredT = undefined;
var cred_len: posix.socklen_t = @sizeOf(UcredT);
const rc = linux.getsockopt(
@intCast(client_fd),
c.SOL_SOCKET,
c.SO_PEERCRED,
@ptrCast(&cred),
&cred_len,
);
if (@as(isize, @bitCast(@as(usize, rc))) < 0) {
try stdout.print("[server] getsockopt failed\n", .{});
return;
}
try stdout.print("[server] peer credentials:\n", .{});
try stdout.print(" PID: {d}\n", .{cred.pid});
try stdout.print(" UID: {d}\n", .{cred.uid});
try stdout.print(" GID: {d}\n", .{cred.gid});
// you could now check: is uid == 0? allow admin commands.
// is uid in a specific group? allow read-only access.
// is pid on our approved list? etc.
var buf: [128]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[server] message from pid {d}: {s}\n", .{ cred.pid, buf[0..n] });
_ = try posix.write(client_fd, "authenticated");
}
fn runCredClient() !void {
const stdout = std.io.getStdOut().writer();
std.time.sleep(100 * std.time.ns_per_ms);
const client_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(client_fd);
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.connect(client_fd, &addr.any, addr.getOsSockLen());
_ = try posix.write(client_fd, "requesting admin access");
var buf: [128]u8 = undefined;
const n = try posix.read(client_fd, &buf);
try stdout.print("[client] server says: {s}\n", .{buf[0..n]});
}
pub fn main() !void {
const pid = try posix.fork();
if (pid == 0) {
runCredClient() catch {};
std.process.exit(0);
}
runCredServer() catch |err| {
std.debug.print("server error: {}\n", .{err});
};
_ = std.posix.waitpid(pid, 0);
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
}
This is powerful. The kernel fills in the credentials -- the client cannot fake them. Unlike TCP where you might use TLS client certificates or bearer tokens for authentication, Unix domain sockets give you kernel-verified identity for free. Docker uses this to check that only root (or members of the docker group) can issue commands to the Docker daemon. Systemd uses it to verify which service is talking to the journal.
NB: SO_PEERCRED is Linux-specific. On macOS and FreeBSD you'd use LOCAL_PEERCRED or getpeereid() instead. The concept is the same but the API differs across Unix variants.
File descriptor passing: the Unix magic trick
This is where Unix domain sockets do something that NO other IPC mechanism can do: you can send an open file descriptor from one process to another. Not a copy of the file's contents -- the actual kernel file descriptor, so the receiving process can use it as if it had opened the file itself.
Think about what this means. Process A opens a file that process B doesn't have permission to open. Process A sends the file descriptor to process B over a Unix socket. Process B can now read and write that file, even though it couldn't have opened it directly. The kernel handles the translation -- fd 7 in process A might become fd 12 in process B, but they both point to the same kernel file object.
This is how privilege separation works in programs like OpenSSH. The unprivileged child process can't open privileged resources directly, but the privileged parent can open them and pass the fd over a Unix socket.
The mechanism uses sendmsg and recvmsg with "ancillary data" (also called "control messages"). It's not the prettiest API -- it dates back to 4.4BSD in the early 1990s -- but it works:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const c = @cImport({
@cInclude("sys/socket.h");
@cInclude("string.h");
});
const SOCKET_PATH = "/tmp/zig_fdpass.sock";
// ancillary data size for one file descriptor
const CMSG_SPACE_FD = @sizeOf(linux.cmsghdr) + @sizeOf(posix.fd_t);
// aligned size
const CMSG_BUF_SIZE = comptime blk: {
const raw = CMSG_SPACE_FD;
const align_to = @alignOf(linux.cmsghdr);
break :blk (raw + align_to - 1) & ~@as(usize, align_to - 1);
};
fn sendFd(sock: posix.fd_t, fd_to_send: posix.fd_t) !void {
var data_buf = [_]u8{'F'}; // must send at least 1 byte of real data
var iov = [_]posix.iovec{.{
.base = &data_buf,
.len = 1,
}};
var cmsg_buf: [CMSG_BUF_SIZE + 64]u8 align(@alignOf(linux.cmsghdr)) = undefined;
@memset(&cmsg_buf, 0);
const cmsg: *linux.cmsghdr = @ptrCast(&cmsg_buf);
cmsg.level = c.SOL_SOCKET;
cmsg.type = c.SCM_RIGHTS;
cmsg.len = @intCast(@sizeOf(linux.cmsghdr) + @sizeOf(posix.fd_t));
// copy the fd into the cmsg data area
const fd_ptr: *posix.fd_t = @ptrCast(@alignCast(@as([*]u8, @ptrCast(cmsg)) + @sizeOf(linux.cmsghdr)));
fd_ptr.* = fd_to_send;
const msg = posix.msghdr_const{
.name = null,
.namelen = 0,
.iov = &iov,
.iovlen = 1,
.control = &cmsg_buf,
.controllen = cmsg.len,
.flags = 0,
};
const sent = linux.sendmsg(@intCast(sock), @ptrCast(&msg), 0);
if (@as(isize, @bitCast(@as(usize, sent))) < 0) return error.SendFailed;
}
fn recvFd(sock: posix.fd_t) !posix.fd_t {
var data_buf: [1]u8 = undefined;
var iov = [_]posix.iovec{.{
.base = &data_buf,
.len = 1,
}};
var cmsg_buf: [CMSG_BUF_SIZE + 64]u8 align(@alignOf(linux.cmsghdr)) = undefined;
@memset(&cmsg_buf, 0);
var msg = posix.msghdr{
.name = null,
.namelen = 0,
.iov = &iov,
.iovlen = 1,
.control = &cmsg_buf,
.controllen = @intCast(cmsg_buf.len),
.flags = 0,
};
const recvd = linux.recvmsg(@intCast(sock), @ptrCast(&msg), 0);
if (@as(isize, @bitCast(@as(usize, recvd))) <= 0) return error.RecvFailed;
const cmsg: *linux.cmsghdr = @ptrCast(@alignCast(&cmsg_buf));
if (cmsg.level != c.SOL_SOCKET or cmsg.type != c.SCM_RIGHTS) {
return error.NoCmsgRights;
}
const fd_ptr: *const posix.fd_t = @ptrCast(@alignCast(@as([*]const u8, @ptrCast(cmsg)) + @sizeOf(linux.cmsghdr)));
return fd_ptr.*;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
// create a test file with some content
{
var f = try std.fs.cwd().createFile("/tmp/zig_fdpass_test.txt", .{});
defer f.close();
try f.writeAll("Secret message that only the server can read!\n");
}
const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(server_fd);
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
try posix.listen(server_fd, 1);
const pid = try posix.fork();
if (pid == 0) {
// child: connect and receive the fd
std.time.sleep(100 * std.time.ns_per_ms);
posix.close(server_fd);
const client_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(client_fd);
const caddr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.connect(client_fd, &caddr.any, caddr.getOsSockLen());
// receive the passed file descriptor
const received_fd = try recvFd(client_fd);
defer posix.close(received_fd);
const stderr = std.io.getStdErr().writer();
try stderr.print("[child] received fd {d}, reading content:\n", .{received_fd});
var buf: [256]u8 = undefined;
const n = try posix.read(received_fd, &buf);
try stderr.print("[child] file says: {s}", .{buf[0..n]});
std.process.exit(0);
}
// parent: open the file and send the fd to the child
var client_addr_buf: posix.sockaddr = undefined;
var client_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client_fd = try posix.accept(server_fd, &client_addr_buf, &client_len);
defer posix.close(client_fd);
const file = try std.fs.cwd().openFile("/tmp/zig_fdpass_test.txt", .{});
defer file.close();
try stdout.print("[parent] sending fd {d} to child\n", .{file.handle});
try sendFd(client_fd, file.handle);
_ = std.posix.waitpid(pid, 0);
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
std.fs.cwd().deleteFile("/tmp/zig_fdpass_test.txt") catch {};
}
The sendmsg/recvmsg API with SCM_RIGHTS is admittedly gnarly. The control message header (cmsghdr) structure, the alignment requirements, the fact that you MUST send at least one byte of regular data alongside the ancillary data -- all of it feels archaic. Because it is. This API was designed in the 1980s and hasn't changed since.
But what it enables is remarkable. File descriptor passing is the foundation of privilege separation, pre-forking servers (the parent opens sockets and passes them to worker children), and container runtimes. When you run docker exec, the Docker daemon opens a PTY and passes its file descriptor to the container's init process over a Unix socket. That's this exact mechanism.
Unix sockets vs everything else
We've now covered quite a few IPC mechanisms in this series. Let me put them side by side so you can see when to use what:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print(
\\IPC Mechanism Comparison:
\\
\\| Mechanism | Direction | Related? | Speed | Boundaries | FD Pass |
\\|-----------------|------------|----------|----------|------------|---------|
\\| Pipe | Uni | Yes* | Fast | No (byte) | No |
\\| Named Pipe | Uni | No | Fast | No (byte) | No |
\\| Shared Memory | Bidir | No | Fastest | No (raw) | No |
\\| TCP Loopback | Bidir | No | Slow** | No (byte) | No |
\\| Unix Stream | Bidir | No | Fast | No (byte) | YES |
\\| Unix Dgram | Bidir | No | Fast | Yes (msg) | YES |
\\| Signals | Uni | No | N/A | N/A | No |
\\
\\* Pipes work between fork()-related processes only (or via fd passing)
\\** TCP loopback goes through the full network stack
\\
, .{});
// let's actually benchmark unix stream vs tcp loopback
const iterations: u32 = 100_000;
const msg = "benchmark-message-payload!";
// Unix socket benchmark
const BENCH_SOCK = "/tmp/zig_bench.sock";
std.fs.cwd().deleteFile(BENCH_SOCK) catch {};
const unix_server = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0);
const unix_addr = try std.net.Address.initUnix(BENCH_SOCK);
try std.posix.bind(unix_server, &unix_addr.any, unix_addr.getOsSockLen());
try std.posix.listen(unix_server, 1);
const pid = try std.posix.fork();
if (pid == 0) {
std.posix.close(unix_server);
std.time.sleep(50 * std.time.ns_per_ms);
const cli = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0);
const caddr = try std.net.Address.initUnix(BENCH_SOCK);
try std.posix.connect(cli, &caddr.any, caddr.getOsSockLen());
var i: u32 = 0;
while (i < iterations) : (i += 1) {
_ = try std.posix.write(cli, msg);
var buf: [64]u8 = undefined;
_ = try std.posix.read(cli, &buf);
}
std.posix.close(cli);
std.process.exit(0);
}
var srv_client_addr: std.posix.sockaddr = undefined;
var srv_client_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr);
const srv_cli = try std.posix.accept(unix_server, &srv_client_addr, &srv_client_len);
const unix_start = std.time.nanoTimestamp();
var j: u32 = 0;
while (j < iterations) : (j += 1) {
var buf: [64]u8 = undefined;
_ = try std.posix.read(srv_cli, &buf);
_ = try std.posix.write(srv_cli, "ok");
}
const unix_elapsed = std.time.nanoTimestamp() - unix_start;
const unix_us = @divTrunc(unix_elapsed, 1000);
_ = std.posix.waitpid(pid, 0);
std.posix.close(srv_cli);
std.posix.close(unix_server);
std.fs.cwd().deleteFile(BENCH_SOCK) catch {};
try stdout.print("Unix socket: {d} round-trips in {d} us ({d} us/trip)\n", .{
iterations,
unix_us,
@divTrunc(unix_us, iterations),
});
}
The numbers you'll typically see: Unix domain sockets are about 2x faster than TCP loopback for small messages, and the gap widens with larger payloads because TCP has to compute checksums and manage congestion state even for local connections. Shared memory is still the fastest (no kernel buffer copies at all), but it requires manual synchronization -- as we saw in episode 66, you need atomics or semaphores to coordinate access.
The general rule: if both processes are on the same machine and you want something that "just works" with a clean API, Unix domain sockets are the sweet spot. If you need raw speed and can handle the synchronization complexity, shared memory wins. If processes might be on different machines now or in the future, TCP is the only option.
Practical example: a command daemon
Let's put it all together and build something useful -- a daemon that listens on a Unix socket and accepts structured commands from clients. This is the same pattern used by Docker, systemd, and almost every Unix service that has a control interface:
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const c = @cImport({
@cInclude("sys/socket.h");
});
const SOCKET_PATH = "/tmp/zig_daemon.sock";
const MAX_MSG = 1024;
const Command = enum {
status,
uptime,
echo,
shutdown,
unknown,
};
fn parseCommand(input: []const u8) struct { cmd: Command, arg: []const u8 } {
const trimmed = std.mem.trim(u8, input, " \n\r\t");
if (trimmed.len == 0) return .{ .cmd = .unknown, .arg = "" };
// find first space to split command from argument
const space_idx = std.mem.indexOfScalar(u8, trimmed, ' ');
const cmd_str = if (space_idx) |idx| trimmed[0..idx] else trimmed;
const arg = if (space_idx) |idx| trimmed[idx + 1 ..] else "";
if (std.mem.eql(u8, cmd_str, "status")) return .{ .cmd = .status, .arg = arg };
if (std.mem.eql(u8, cmd_str, "uptime")) return .{ .cmd = .uptime, .arg = arg };
if (std.mem.eql(u8, cmd_str, "echo")) return .{ .cmd = .echo, .arg = arg };
if (std.mem.eql(u8, cmd_str, "shutdown")) return .{ .cmd = .shutdown, .arg = arg };
return .{ .cmd = .unknown, .arg = arg };
}
fn handleClient(client_fd: posix.fd_t, start_time: i128, request_count: *u64) !bool {
var buf: [MAX_MSG]u8 = undefined;
const n = posix.read(client_fd, &buf) catch return false;
if (n == 0) return false; // client disconnected
const parsed = parseCommand(buf[0..n]);
request_count.* += 1;
var response_buf: [MAX_MSG]u8 = undefined;
const response = switch (parsed.cmd) {
.status => std.fmt.bufPrint(&response_buf, "OK | requests served: {d}\n", .{request_count.*}) catch "error",
.uptime => blk: {
const now = std.time.nanoTimestamp();
const elapsed_s = @divTrunc(now - start_time, std.time.ns_per_s);
break :blk std.fmt.bufPrint(&response_buf, "uptime: {d}s\n", .{elapsed_s}) catch "error";
},
.echo => std.fmt.bufPrint(&response_buf, "{s}\n", .{parsed.arg}) catch "error",
.shutdown => "shutting down\n",
.unknown => "error: unknown command. try: status, uptime, echo <msg>, shutdown\n",
};
_ = posix.write(client_fd, response) catch {};
return parsed.cmd == .shutdown;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
defer posix.close(server_fd);
const addr = try std.net.Address.initUnix(SOCKET_PATH);
try posix.bind(server_fd, &addr.any, addr.getOsSockLen());
try posix.listen(server_fd, 5);
// set socket permissions so only our user can connect
// (in production you'd also chown to the right user/group)
const start_time = std.time.nanoTimestamp();
var request_count: u64 = 0;
var should_shutdown = false;
try stdout.print("Daemon listening on {s}\n", .{SOCKET_PATH});
try stdout.print("Connect with: echo 'status' | socat - UNIX-CONNECT:{s}\n", .{SOCKET_PATH});
// set up signal handling for graceful shutdown
var shutdown_flag: bool = false;
_ = &shutdown_flag;
while (!should_shutdown) {
var pollfds = [_]linux.pollfd{.{
.fd = server_fd,
.events = linux.POLL.IN,
.revents = 0,
}};
const ready = linux.poll(&pollfds, 1, 2000);
if (@as(isize, @bitCast(@as(usize, ready))) <= 0) continue;
if (pollfds[0].revents & linux.POLL.IN != 0) {
var client_addr_buf: posix.sockaddr = undefined;
var client_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client_fd = posix.accept(server_fd, &client_addr_buf, &client_len) catch continue;
// check who connected (peer creds)
const UcredT = extern struct { pid: i32, uid: u32, gid: u32 };
var cred: UcredT = undefined;
var cred_len: posix.socklen_t = @sizeOf(UcredT);
_ = linux.getsockopt(
@intCast(client_fd),
c.SOL_SOCKET,
c.SO_PEERCRED,
@ptrCast(&cred),
&cred_len,
);
try stdout.print("[daemon] connection from pid={d} uid={d}\n", .{ cred.pid, cred.uid });
should_shutdown = handleClient(client_fd, start_time, &request_count) catch false;
posix.close(client_fd);
}
}
try stdout.print("Daemon shutting down. Served {d} requests.\n", .{request_count});
std.fs.cwd().deleteFile(SOCKET_PATH) catch {};
}
To test this daemon, build and run it, then from another terminal:
echo "status" | socat - UNIX-CONNECT:/tmp/zig_daemon.sock
echo "uptime" | socat - UNIX-CONNECT:/tmp/zig_daemon.sock
echo "echo Hello World" | socat - UNIX-CONNECT:/tmp/zig_daemon.sock
echo "shutdown" | socat - UNIX-CONNECT:/tmp/zig_daemon.sock
Or if you don't have socat installed, you can write a quick Zig client (exercise for the reader -- or just use the client pattern from the first example in this episode).
This daemon demonstrates the full Unix socket server pattern:
- Bind and listen on a known path
- Poll for connections with a timeout (so we can check shutdown flags)
- Accept each client, check their credentials, handle their request, close
- Clean up the socket file on exit
Having said that, this is a sequential server -- it handles one client at a time. A production daemon would either fork a child per connection (like our signal-driven process pool from last episode's exercises), use threads, or use a non-blocking event loop with poll/epoll to multiplex clients. The next episode will cover how to turn a process like this into an actual background daemon -- detaching from the terminal, redirecting I/O, writing a PID file, and all the other bookkeeping that separates a toy server from a real service.
Exercises
Build a Unix socket chat server that handles multiple simultaneous clients. The server should use
poll()to multiplex the listening socket and all connected client file descriptors in a single array. When a message arrives from any client, broadcast it to all other connected clients (prefixed with the sender's PID from SO_PEERCRED). Track connected clients in a fixed-size array (max 16). Handle client disconnects gracefully (remove from the array, close the fd). Write a matching client that reads from stdin and sends to the server, while also printing messages received from the server.Write a file descriptor proxy using fd passing. Process A (the "opener") opens files that Process B (the "worker") requests by name. The worker sends a filename over the Unix socket. The opener checks if the file exists and if the filename is in an allowed list (a simple hardcoded whitelist of 3-4 safe paths). If allowed, the opener opens the file and passes the fd back to the worker using SCM_RIGHTS. If denied, the opener sends back an error message. The worker then reads from the received fd and prints the contents. This simulates privilege separation -- the worker never touches the filesystem directly.
Build a Unix socket-based metrics collector. A "collector" daemon listens on an abstract socket (
\x00zig-metrics). Multiple "reporter" processes connect and periodically send JSON-formatted metric lines (e.g.{"name":"cpu","value":42.5}). The collector parses each line, maintains running averages per metric name in a hash map, and responds with the current average for that metric. When the collector receives the metric name "dump", it prints all current averages to stdout. Usestd.jsonfor parsing the metric messages.
Dusssssss, wat hebben we nou geleerd?
- Unix domain sockets are the standard IPC mechanism for local communication on Unix systems -- same API as TCP sockets but skip the entire network stack for 2-3x better performance
- SOCK_STREAM gives you connection-oriented reliable byte streams (like TCP); SOCK_DGRAM gives you message-oriented datagrams (like UDP but reliable since there's no network)
- Binding to a filesystem path creates a special socket file used for rendezvous -- you must delete stale socket files before binding, and clean up on exit
- Abstract sockets (Linux-only, names starting with
\0) live in a kernel namespace and need no filesystem cleanup, but lack file permission controls - SO_PEERCRED lets you query the PID, UID, and GID of a connected peer -- kernel-verified identity for free, used by Docker, systemd, and every serious Unix daemon
- File descriptor passing via SCM_RIGHTS lets you send open fds between unrelated processes -- the foundation of privilege separation, pre-forking servers, and container runtimes
- Compared to other IPC: Unix sockets are slower than shared memory but need no manual synchronization; faster than TCP loopback; bidirectional unlike pipes; and work between unrelated processes unlike anonymous pipes
- The daemon pattern (bind, listen, poll, accept, handle, cleanup) is the same whether you're building a Docker-style control socket or a simple service monitor
Next we're going deeper into the daemon lifecycle itself -- how to properly detach from a terminal, create a new session, redirect standard I/O, write PID files, and handle the double-fork pattern that turns a regular process into a proper background service.
Thanks for reading!