Networking with the Standard Library
Basic Network Protocols
Because TCP provides an unstructured byte stream without built-in message boundaries, two writes (write_all(b"msg1") and write_all(b"msg2")) may arrive at the receiver merged into a single read() call (b"msg1msg2") or split across multiple read() calls (b"ms", b"g1m", b"sg2"). An application-layer network protocol defines the framing rules that allow a receiver to reconstruct exact message boundaries from a byte stream.
Sender calls: write("HELLO") then write("WORLD")
TCP Stream: [ H | E | L | L | O | W | O | R | L | D ] (No boundaries!)
Solution 1 — Delimiter Framing (Text Protocols: HTTP/1.1 headers, Redis RESP, SMTP):
[ H | E | L | L | O | \n | W | O | R | L | D | \n ]
Solution 2 — Length-Prefixed Framing (Binary Protocols: HTTP/2, gRPC, PostgreSQL):
[ 0x00, 0x00, 0x00, 0x05 | H | E | L | L | O | 0x00, 0x00, 0x00, 0x05 | W | O | R | L | D ]
|<-- u32 big-endian = 5 ->|<--- 5 bytes ---->|
Comparing Common Framing Strategies
| Framing Strategy | Mechanism | Advantages | Pitfalls to Guard Against |
|---|---|---|---|
| Length-Prefixed | Fixed-size big-endian length header (e.g., u32::to_be_bytes) followed by N payload bytes. |
Handles arbitrary binary data; exact buffer allocation via read_exact. |
Always cap maximum allowed N before allocating vec![0u8; N] to prevent OOM attacks. |
| Delimiter-Terminated | Messages end with \n or \r\n read via BufRead::read_line or read_until. |
Human-readable; easy to debug with nc / telnet. |
Payload cannot contain raw unescaped delimiter; must bound line length via .take(MAX). |
| Close-Delimited | Sender transmits payload and closes write half (Shutdown::Write). |
Zero framing overhead. | Only supports a single message per direction per connection. |
Implementing a Length-Prefixed Binary Frame Protocol
Network protocols standardize on big-endian (network byte order) integers via .to_be_bytes() and from_be_bytes(), combined with Read::read_exact and Write::write_all:
use std::io::{self, ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
const MAX_FRAME_SIZE: usize = 64 * 1024; // 64 KiB safety limit
fn write_frame(mut writer: impl Write, payload: &[u8]) -> io::Result<()> {
let len = u32::try_from(payload.len())
.map_err(|_| io::Error::new(ErrorKind::InvalidInput, "frame exceeds u32::MAX"))?;
writer.write_all(&len.to_be_bytes())?;
writer.write_all(payload)?;
writer.flush()
}
fn read_frame(mut reader: impl Read) -> io::Result<Option<Vec<u8>>> {
let mut len_bytes = [0u8; 4];
match reader.read_exact(&mut len_bytes) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_be_bytes(len_bytes) as usize;
if len > MAX_FRAME_SIZE {
return Err(io::Error::new(
ErrorKind::InvalidData,
format!("frame length {len} exceeds limit {MAX_FRAME_SIZE}"),
));
}
let mut payload = vec![0u8; len];
reader.read_exact(&mut payload)?;
Ok(Some(payload))
}
fn main() -> io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let mut client = TcpStream::connect(addr)?;
let (mut server, _) = listener.accept()?;
// Send two distinct binary frames back-to-back over the same TCP stream
write_frame(&mut client, b"First Frame")?;
write_frame(&mut client, b"Second Frame with \n newline inside")?;
drop(client);
// Server reconstructs exact frame boundaries despite TCP coalescing
while let Some(frame) = read_frame(&mut server)? {
println!("Decoded frame: {:?}", String::from_utf8_lossy(&frame));
}
Ok(())
}
Decoded frame: "First Frame"
Decoded frame: "Second Frame with \n newline inside"