Semaphore logo

Network protocol in Rust

Bytes on the wire,
framed by hand.

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.

View on GitHub Try the live demo
How to use this playground
Pick a message on the left, watch it become bytes, then feed those bytes to the decoder one at a time to see a frame reassemble.

pick a message, watch it become bytes, then decode those bytes one at a time

Encoder

len 4 byte length prefix tag 1 byte message kind body the rest of the frame

Streaming decoder

Ready.

Oversize length prefix

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.

Not tested yet.

The wire format

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.

length u32, big-endian
payload exactly `length` bytes

Two terminals, one socket

Start the server in one terminal, then drive it from another. Real output from the CLI, no async runtime behind it.

# terminal 1: run the server $ cargo run -- serve --addr 127.0.0.1:7878 semaphore server listening on 127.0.0.1:7878 # terminal 2: talk to it $ cargo run -- ping --addr 127.0.0.1:7878 PONG $ cargo run -- set --addr 127.0.0.1:7878 name semaphore OK $ cargo run -- get --addr 127.0.0.1:7878 name semaphore $ cargo run -- get --addr 127.0.0.1:7878 missing (not found)

Why it exists

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.

Framing codec

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.

Typed protocol

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.

TCP server and client

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

Six pieces, each unit-tested on its own

Every layer is a pure piece you can read and test in isolation. These are the real building blocks in the crate.

Frame codec frame.rs

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.

Streaming decoder state machine

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.

Frame cap 16 MiB

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.

Typed messages protocol.rs

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.

Threaded server net.rs

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.

Typed errors no strings

Each layer defines its own enum: FrameError, ProtocolError, NetError. Truncation, oversize length, and malformed bodies are values the caller can match on, propagated with ?.

Message types

The payload of a frame is a small typed protocol, a one-byte tag followed by a tag-specific body.

MessageTagBody
PING0x01none
PONG0x02none
SET0x03key (u16-len string), value (u32-len bytes)
GET0x04key (u16-len string)
VALUE0x05value (u32-len bytes)
NOT_FOUND0x06none

The choices behind it

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.

Length prefix, not delimiters

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.

A hard cap before allocation

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.

Blocking std::net, no async

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.

Typed errors at every layer

FrameError, ProtocolError, and NetError instead of strings or panics. Truncation, oversize, and malformed bodies are all values a caller can match on.

Small on purpose

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.

Use it

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.

serve

semaphore serve --addr 127.0.0.1:7878 runs the blocking TCP server with an in-memory key-value store.

ping / set / get

Client subcommands over the same wire protocol. ping expects PONG; set replies OK; get prints the value or (not found).

Library

Client::connect(addr) then client.set(key, bytes) and client.get(key): blocking request/reply calls that return a typed Result.

Tests

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