Network protocol in Rust
Semaphore is a length-prefixed binary message protocol built from scratch, with a framing codec you can read end to end, plus a TCP server and client that speak it.
pick a message, watch it become bytes, then decode those bytes one at a time
len 4 byte length prefix tag 1 byte message kind body the rest of the frame
The decoder checks the declared length against the frame cap before it allocates anything for the payload. A length prefix above the cap is rejected on the spot.
Every message on the wire is a 4 byte big-endian length prefix followed by exactly that many payload bytes. That is the entire framing layer.
Start the server in one terminal, then drive it from another. Real output from the CLI, no async runtime behind it.
Most people reach for a framework the moment two processes need to talk over a socket. Semaphore takes the other path, small enough to read in one sitting, with every layer a pure, unit-tested piece you can hold in your head.
A pure, dependency-free encoder and a streaming decoder that reassembles a frame correctly even when it arrives split across many reads, and rejects an oversize length prefix before allocating anything.
PING and PONG, plus a small key-value SET and GET request-response protocol encoded on top of frames, with a one-byte tag and bounds-checked bodies.
Plain std::net and OS threads, no async runtime. The server holds an in-memory map, the client issues blocking SET, GET, and PING calls.
The primitives that matter
Every layer is a pure piece you can read and test in isolation. These are the real building blocks in the crate.
A frame is a Vec<u8> payload. encode writes a 4-byte big-endian length prefix then the bytes. No magic number, version, or checksum: correctness comes from the decoder being strict about the length field.
A two-state machine (reading length, then reading payload) that keeps state between calls. Feeding bytes one at a time produces exactly the same frames as feeding them all at once.
MAX_FRAME_LEN bounds the payload. A declared length above the cap is rejected before any buffer is allocated, so a bad prefix costs a u32 comparison, not attacker-chosen memory.
A one-byte tag plus a tag-specific body: PING, PONG, SET, GET, VALUE, NOT_FOUND. Decoding bounds-checks every read before slicing, so a truncated body is a typed error, not a panic.
A blocking accept() loop that spawns one OS thread per connection and shares a single HashMap behind a Mutex. Each thread reads a message, reacts, writes a reply, repeats.
Each layer defines its own enum: FrameError, ProtocolError, NetError. Truncation, oversize length, and malformed bodies are values the caller can match on, propagated with ?.
The payload of a frame is a small typed protocol, a one-byte tag followed by a tag-specific body.
| Message | Tag | Body |
|---|---|---|
| PING | 0x01 | none |
| PONG | 0x02 | none |
| SET | 0x03 | key (u16-len string), value (u32-len bytes) |
| GET | 0x04 | key (u16-len string) |
| VALUE | 0x05 | value (u32-len bytes) |
| NOT_FOUND | 0x06 | none |
Most people reach for a framework the moment two processes need to talk over a socket. Semaphore takes the other path. These are the deliberate trade-offs, straight from the design notes.
Every message is a 4-byte big-endian length then exactly that many bytes. No escaping, no scanning for a terminator: the decoder knows the payload size before it reads a byte of it.
The length is checked against a 16 MiB cap before any payload buffer exists. A malicious or corrupt prefix costs a comparison, not attacker-chosen memory. The decoder resets so the next frame can still be read.
The server is a plain accept() loop with one OS thread per connection. The whole request lifecycle is traceable in a debugger without reasoning about a task scheduler.
FrameError, ProtocolError, and NetError instead of strings or panics. Truncation, oversize, and malformed bodies are all values a caller can match on.
No connection pooling, no pipelining, no version byte or checksum. Around 690 lines of Rust across frame, protocol, and net, small enough to read in one sitting.
One crate, no runtime dependencies beyond clap for the CLI. Drive it from the command line, embed it as a library, or run the tests.
semaphore serve --addr 127.0.0.1:7878 runs the blocking TCP server with an in-memory key-value store.
Client subcommands over the same wire protocol. ping expects PONG; set replies OK; get prints the value or (not found).
Client::connect(addr) then client.set(key, bytes) and client.get(key): blocking request/reply calls that return a typed Result.
cargo test runs 14 unit tests (codec roundtrips, byte-at-a-time reassembly, oversize rejection, malformed bodies) plus a live-server integration test.
cargo run -- serve --addr 127.0.0.1:7878 cargo run -- ping --addr 127.0.0.1:7878 cargo run -- set --addr 127.0.0.1:7878 name semaphore cargo run -- get --addr 127.0.0.1:7878 name cargo test