Somewhere along the way, “two processes on the same machine need to talk” started defaulting to an HTTP server on a loopback port, and the habit is so deep that most engineers never stop to ask what the TCP handshake, the header block, and the JSON parser are actually buying when the client and the server share a kernel and a filesystem. LinSight’s daemon answers that question by not paying any of it: the GUI, the CLI, and every third-party tool that wants live sensor data all speak postcard-framed messages over a plain Unix socket at $XDG_RUNTIME_DIR/linsight.sock, and the whole wire protocol is one crate with a FrameReader, a FrameWriter, and a version handshake.
The reason that matters is the shape of the workload, because a system monitor is not a request-response service that wakes up a few times a minute, it is a subscription pump that pushes a Sample message to every interested client each time a sensor’s period elapses, and when that pump runs all day, every day, next to the thing the user is actually trying to measure, the daemon’s own footprint, in bytes on the wire, microseconds per encode, and resident memory while idle, stops being a nicety and starts being part of the measurement quality.
What postcard actually is on the wire
Postcard is a serde-based binary format that encodes exactly the bytes the data needs and nothing else, so integers shrink to varints, enums are a single discriminant byte plus payload, and there is no schema file, no code generation step, and no field names riding the wire in every single message the way JSON insists on. Framing is the part people usually overthink, and here it is a four-byte little-endian length prefix followed by the postcard body, with a MAX_FRAME_BYTES cap of 1 MiB that treats anything bigger as corrupted or adversarial rather than trying to be clever about streaming:
fn read_frame(&mut self) -> Result<Vec<u8>, FrameError> {
let (len, first) = self.read_frame_header()?;
let mut body = vec![0u8; len as usize];
body[0] = first;
if len > 1 {
self.inner.read_exact(&mut body[1..])?;
}
Ok(body)
}
That is the whole framing layer, and the decode side is postcard::from_bytes(&bytes), which means the parser that stands between a client and its data is a length check and a memcpy, not a state machine that tokenizes text and builds a DOM-shaped tree it then walks to find the one float it wanted. One detail worth pointing at, because it is the kind of thing a reader will poke: the body[0] = first line looks like it could panic on a zero-length frame, except every ClientMsg and ServerMsg is a serde enum, and a postcard-encoded enum always carries at least its discriminant byte, so the shortest legal frame on this wire is one byte and the edge case is excluded by construction rather than by a guard clause.
For our message shapes the difference lands at roughly an order of magnitude, since a Sample is a sensor ID, a timestamp, a unit tag, and a value, which is tens of bytes in postcard and several hundred bytes of braces, quotes, and repeated field names in JSON, and the budget we hold the protocol to in CI reflects that: 64 bytes per sample on the wire, with a sample_wire_size_within_budget test that fails the build if someone adds a field and blows the envelope without noticing.
Why a Unix socket and not a loopback port
The socket choice is the other half of the decision, and it buys three things that a TCP port on 127.0.0.1 simply does not have. The first is that the filesystem is the access-control model, so the socket lives in the user’s runtime directory with the user’s permissions, there is no port to collide with, no firewall rule to explain, and no way for some other process on the box to wander in without the file permissions already being wrong. The second is that the kernel does the buffering and the wakeup work, which fits the daemon’s deliberate shape, sync plus polling, no async runtime in the hot path, one thread that reads Subscribe messages and a pump loop that ticks the scheduler every 50 ms and writes whatever came due. The third is that subscriptions are first-class in the protocol itself, so a client says Subscribe for the sensors it wants, the scheduler refcounts each sensor and only samples what at least one client asked for, and when the GUI’s client handle drops it sends Goodbye and the daemon can put the sensors back to sleep, which is why the idle daemon sits at roughly 5 MB of RSS and effectively zero CPU while still answering a fresh subscription with its first sample in about 15 milliseconds.
The CLI and the GUI are equal citizens on this socket, which is a point we keep coming back to across the whole product line, since linsight-cli read cpu.util --count 5 and the Kirigami tile showing the same number are decoding the same framed bytes from the same endpoint, and there is no second, lesser API for scripts to discover is missing features.
The costs, stated honestly
The tradeoff is real and it is worth naming, because a binary protocol on a Unix socket gives up the things HTTP defenders reach for first: you cannot point a browser at it, and while curl --unix-socket has existed since 2014 and will happily connect, what comes back is postcard binary, which is no friendlier than reading a hex dump, so debugging a misbehaving client means writing ten lines of Rust against the protocol crate, and that friction is the price of admission. The subtler cost is the one that comes free with any schemaless serde format: postcard is not self-describing, there is no IDL and no generated stubs, so a client in another language means reimplementing the message shapes by hand, and a drifted field order between daemon and client fails at decode time instead of at compile time, which is exactly why the protocol opens with a PROTOCOL_VERSION handshake that rejects a mismatched peer before a single payload byte is trusted. We pay those costs willingly in the daemon because the alternative is worse for this workload, but we do not pretend HTTP is never the answer, which is why LinSight also ships a Prometheus exporter that scrapes synchronously and serves text on a normal port, and why the remote-monitoring story is a separate linsight-tunnel binary that bridges the Unix socket to mTLS over TCP with rustls instead of bolting TLS onto the local path where it would only add handshake cost to a connection that never leaves the machine.
The honest summary is that protocol choice is a workload question, not a fashion question, and when the workload is “push small typed messages between processes on one host, constantly,” the smallest thing that works is a length-prefixed binary frame on a socket the filesystem already guards, and the biggest thing that works is a whole HTTP stack you did not need.
The older lesson underneath
None of this is new, and that is rather the point, because the Windows 3.1 boxes some of us started on did the same dance with named pipes and DDE, the Unix world did it with domain sockets decades before REST was a slide in a deck, and every generation rediscovers that IPC over the kernel’s own primitives beats tunneling structured data through a text protocol designed for hypertext documents. Postcard happens to be the pleasant modern face of that old idea, serde-derive on one side and from_bytes on the other with no IDL in between, and the combination with a plain Unix socket gives a monitoring daemon a wire protocol measured in tens of bytes and single-digit microseconds, which is exactly the kind of boring that a hot path should aspire to.
