Frontend Labs
⋯

32. Networking with std and Tokio: TCP, UDP, and framing

32.1 A threaded TCP echo server with std

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;

fn handle(stream: TcpStream) -> std::io::Result<()> {
    let peer = stream.peer_addr()?;
    stream.set_read_timeout(Some(Duration::from_secs(30)))?; // idle clients cannot hold threads forever
    stream.set_nodelay(true)?;                               // disable Nagle for small interactive writes
    let mut writer = stream.try_clone()?;
    for line in BufReader::new(stream).lines() {
        let line = line?;
        writeln!(writer, "echo: {line}")?;
    }
    println!("{peer} disconnected");
    Ok(())
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:7878")?;  // socket() + bind() + listen()
    println!("listening on {}", listener.local_addr()?);
    for stream in listener.incoming().take(1) {           // take(1) so the demo exits; remove for a real server
        let stream = stream?;                              // accept()
        std::thread::spawn(move || {
            if let Err(e) = handle(stream) { eprintln!("connection error: {e}"); }
        });
    }
    Ok(())
}
cargo run --bin echo &       # then:
printf 'hello\nworld\n' | nc 127.0.0.1 7878

Every call above maps one-to-one onto the C guide's §21.1 echo server: bind performs socket/setsockopt(SO_REUSEADDR)/bind/listen, and incoming loops over accept. The differences: the socket is closed by Drop, errors are Results instead of -1 and errno, and the buffer cannot overflow.

Across the series: what bind, listen, and accept do on the wire is in Net Ch 19 and Ch 21. Why set_nodelay matters is Ch 26, the 40-millisecond mystery.

32.2 The same server with Tokio, plus length-prefixed framing

TCP is a byte stream, not a message stream. One write of 100 bytes can arrive as two reads of 60 and 40, and two writes can arrive in one read. Every protocol therefore needs framing: delimiters (\n, as in HTTP/1 headers and Redis) or a length prefix (as in HTTP/2, TLS records, gRPC, and Kafka).

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

const MAX_FRAME: u32 = 1 << 20; // 1 MiB: never trust a length field from the network

async fn read_frame(s: &mut TcpStream) -> std::io::Result<Option<Vec<u8>>> {
    let len = match s.read_u32().await {              // 4-byte big-endian length
        Ok(n) => n,
        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), // clean close
        Err(e) => return Err(e),
    };
    if len > MAX_FRAME {
        return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("frame of {len} bytes")));
    }
    let mut buf = vec![0u8; len as usize];
    s.read_exact(&mut buf).await?;                    // loops until all `len` bytes have arrived
    Ok(Some(buf))
}

async fn write_frame(s: &mut TcpStream, payload: &[u8]) -> std::io::Result<()> {
    let len = u32::try_from(payload.len()).map_err(|_| std::io::Error::other("payload too large"))?;
    let mut out = Vec::with_capacity(4 + payload.len());
    out.extend_from_slice(&len.to_be_bytes());
    out.extend_from_slice(payload);
    s.write_all(&out).await                           // one write: cancel-safe at the frame level
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let addr = listener.local_addr()?;

    tokio::spawn(async move {
        loop {
            let (mut sock, _) = listener.accept().await.unwrap();
            tokio::spawn(async move {                 // one TASK per connection, not one thread
                while let Ok(Some(frame)) = read_frame(&mut sock).await {
                    let reply = frame.to_ascii_uppercase();
                    if write_frame(&mut sock, &reply).await.is_err() { break; }
                }
            });
        }
    });

    let mut client = TcpStream::connect(addr).await?;
    for msg in ["hello", "framed", "world"] {
        write_frame(&mut client, msg.as_bytes()).await?;
        let reply = read_frame(&mut client).await?.expect("server replied");
        println!("{}", String::from_utf8_lossy(&reply));
    }
    Ok(())
}

MAX_FRAME is a security control. Without it, a client sending the four bytes FF FF FF FF makes the server allocate 4 GiB. Ch 53 treats every length field from the network this way.

32.3 UDP

use std::net::UdpSocket;
use std::time::{Duration, Instant};

fn main() -> std::io::Result<()> {
    let server = UdpSocket::bind("127.0.0.1:0")?;
    let addr = server.local_addr()?;
    std::thread::spawn(move || {
        let mut buf = [0u8; 1500];                         // one datagram, up to a typical MTU
        while let Ok((n, peer)) = server.recv_from(&mut buf) {
            let _ = server.send_to(&buf[..n], peer);       // echo
        }
    });

    let client = UdpSocket::bind("127.0.0.1:0")?;
    client.set_read_timeout(Some(Duration::from_millis(500)))?; // UDP never tells you about loss
    let mut lost = 0;
    for seq in 0u32..5 {
        let t = Instant::now();
        client.send_to(&seq.to_be_bytes(), addr)?;
        let mut buf = [0u8; 4];
        match client.recv_from(&mut buf) {
            Ok(_) if u32::from_be_bytes(buf) == seq => println!("seq={seq} rtt={:?}", t.elapsed()),
            _ => lost += 1,
        }
    }
    println!("lost {lost}/5");
    Ok(())
}

This is a small version of the networking guide's UDP loss meter lab (Net Ch 20).


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36